fix(studio): keep AI provider and model details private

This commit is contained in:
Clintchiz committed 2026-10-19 23:20:50 +05:30
1 parent 5aec369054
commit 6d0c47fba7
5 files changed
+65 -51

No files matched your search

+16 -16
View File
@@ -18,23 +18,21 @@ type FailureKind =
| "response"
| "request";
const failureMessages: Record<FailureKind, string> = {
auth: "APIMart rejected the server API key. Check APIMART_API_KEY and its permissions, then restart the app.",
auth: "AI generation is unavailable due to a server configuration issue. Contact the site administrator.",
quota:
"APIMart reports insufficient credit or quota. Check the provider account balance and limits.",
"AI generation has reached its service usage limit. Try again later or contact the site administrator.",
model:
"APIMart could not access the configured model. Check that this model is enabled for the server API key.",
rate: "APIMart is rate limiting requests. Wait a minute and generate again.",
unavailable:
"APIMart is temporarily unavailable. Try generating again shortly.",
network:
"The server could not reach APIMart. Check the VM's outbound HTTPS connection and try again.",
"AI generation is unavailable due to a server configuration issue. Contact the site administrator.",
rate: "AI generation is rate limiting requests. Wait a minute and generate again.",
unavailable: "AI generation is temporarily unavailable. Try again shortly.",
network: "The server could not reach the AI service. Try again shortly.",
truncated:
"APIMart stopped before completing all design alternatives. Try a shorter brief or generate again.",
filtered: "APIMart declined this generation. Revise the brief and try again.",
response:
"APIMart returned an empty or unreadable completion. Try generating again.",
"AI generation stopped before completing all design alternatives. Try a shorter brief or generate again.",
filtered:
"This generation could not be completed. Revise the brief and try again.",
response: "AI generation returned an empty or unreadable result. Try again.",
request:
"APIMart rejected the completion request. Check the provider's model configuration.",
"AI generation could not process this request. Try again or contact the site administrator.",
};
class APIMartFailure extends Error {
constructor(
@@ -202,8 +200,7 @@ function synchronousStudioAI(
return json({ error: "Same-origin requests only." }, 403);
if (url.pathname.endsWith("/generation") && request.method === "GET")
return json({
provider: "APIMart",
model,
provider: "AI",
templateVersion: 1,
delivery: "polling",
maxAttempts: 2,
@@ -291,7 +288,7 @@ function synchronousStudioAI(
code: "WRN-STUDIO-PROPOSAL",
path: "$",
message:
"APIMart returned a design that did not meet the schema after both provider requests were used. Generate again. Your draft and previous alternatives are unchanged.",
"AI generation returned a design that did not meet the schema after both attempts were used. Generate again. Your draft and previous alternatives are unchanged.",
},
];
}
@@ -317,6 +314,9 @@ function synchronousStudioAI(
},
];
}
// Public responses retain useful provenance without exposing infrastructure choices.
result.provenance.provider = "AI";
delete result.provenance.model;
return json(result);
} catch (error) {
const message =
+2 -13
View File
@@ -18,19 +18,8 @@ export const studioHelpScript = `(() => {
});
})();`;
export function addStudioGuide(
html: string,
configured: boolean,
model = "gpt-5-mini",
) {
const safeModel = model.replace(
/[&<>"']/g,
(char) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
char
]!,
);
const guide = `<details open class="help"><summary><strong>How to use Design Studio</strong></summary><ol><li><strong>Build manually.</strong> Open Manual design to choose colors, fonts, corner radii, spacing and shadows. Switch light/dark modes, select Update preview to save settings, then export. No AI is required.</li><li><strong>Describe your website for AI.</strong> Open Guided design, enter your product and audience, then choose colors, shapes and spacing with Next.</li><li><strong>Generate and compare.</strong> Click Generate three directions. The first valid alternative appears in the large preview. Review another to compare, then choose Use reviewed proposal to save it. Show current draft returns without accepting.</li><li><strong>Refine it.</strong> Try “make buttons square and use blue as the primary color” under Edit with words. Lock decisions you want to keep.</li><li><strong>Use it in your app.</strong> Open Review &amp; apply and export Theme config or Compiled CSS. This website previews and exports designs; it does not change your deployed app automatically.</li></ol><p>${configured ? `APIMart AI is enabled with ${safeModel}. Generation sends your brief and current design to APIMart only when you request it.` : "AI is not configured. Local presets and supported rule-based edits still work. Set APIMART_API_KEY and APIMART_MODEL in the server .env and restart to enable AI."}</p></details>`;
export function addStudioGuide(html: string, configured: boolean) {
const guide = `<details open class="help"><summary><strong>How to use Design Studio</strong></summary><ol><li><strong>Build manually.</strong> Open Manual design to choose colors, fonts, corner radii, spacing and shadows. Switch light/dark modes, select Update preview to save settings, then export. No AI is required.</li><li><strong>Describe your website for AI.</strong> Open Guided design, enter your product and audience, then choose colors, shapes and spacing with Next.</li><li><strong>Generate and compare.</strong> Click Generate three directions. The first valid alternative appears in the large preview. Review another to compare, then choose Use reviewed proposal to save it. Show current draft returns without accepting.</li><li><strong>Refine it.</strong> Try “make buttons square and use blue as the primary color” under Edit with words. Lock decisions you want to keep.</li><li><strong>Use it in your app.</strong> Open Review &amp; apply and export Theme config or Compiled CSS. This website previews and exports designs; it does not change your deployed app automatically.</li></ol><p>${configured ? "AI design assistance is available. Your brief and current design are sent for processing only when you request generation." : "AI design assistance is currently unavailable. You can still use Manual design, presets and supported rule-based edits."}</p></details>`;
return html
.replace(
'<script type="module"',
-1
View File
@@ -58,7 +58,6 @@ const designStudio: Middleware = async (ctx, next) => {
addStudioGuide(
await response.text(),
!!process.env.APIMART_API_KEY?.trim(),
process.env.APIMART_MODEL?.trim() || APIMART_MODEL,
),
{ status: response.status, headers },
);
+7 -3
View File
@@ -61,6 +61,11 @@ try {
!(await response.clone().text()).includes("How to use Design Studio")
)
throw Error("Studio's usage guide was not included in the compiled app.");
if (
path.startsWith("/design-studio/") &&
/apimart|gpt-5-mini|gpt-6-luna/i.test(await response.clone().text())
)
throw Error("Public Studio content exposed private AI configuration.");
const policy = response.headers.get("content-security-policy") ?? "";
if (path === "/" && !policy.includes("require-trusted-types-for"))
throw Error(
@@ -103,9 +108,8 @@ try {
).json();
if (
process.env.APIMART_API_KEY?.trim() &&
(generation.provider !== "APIMart" ||
generation.model !==
(process.env.APIMART_MODEL?.trim() || "gpt-5-mini") ||
(generation.provider !== "AI" ||
generation.model !== undefined ||
generation.delivery !== "polling" ||
generation.maxAttempts !== 2 ||
generation.maxOutputTokens !== 8192 ||
+40 -18
View File
@@ -86,8 +86,9 @@ test("AI proposals use engine validation, preserve draft source, locks and revis
const response = await handler(await request());
const batch = await response!.json();
expect(batch.alternatives).toHaveLength(3);
expect(batch.provenance.provider).toBe("APIMart");
expect(batch.provenance.model).toBe("gpt-5-mini");
expect(batch.provenance.provider).toBe("AI");
expect(batch.provenance.model).toBeUndefined();
expect(JSON.stringify(batch)).not.toMatch(/APIMart|gpt-5-mini|custom-model/i);
expect(JSON.stringify(system)).toBe(original);
const locked = await handler(
await request(await input(["/tokens/shape.control"])),
@@ -114,7 +115,7 @@ test("metadata and errors never contain the key or raw upstream response", async
new Request("https://components.example/design-studio/api/generation"),
);
const text = await meta!.text();
expect(text).toContain("gpt-5-mini");
expect(text).not.toMatch(/apimart|gpt-5-mini/i);
expect(JSON.parse(text).timeoutMs).toBe(AI_TIMEOUT_MS);
expect(AI_TIMEOUT_MS).toBe(60_000);
expect(text).not.toContain("test-secret");
@@ -122,6 +123,7 @@ test("metadata and errors never contain the key or raw upstream response", async
const body = await failure!.text();
expect(body).not.toContain("test-secret");
expect(body).not.toContain("upstream credentials");
expect(body).not.toMatch(/APIMart|APIMART_API_KEY|gpt-5-mini/i);
expect(body).toContain("WRN-STUDIO-PROVIDER");
});
@@ -139,7 +141,7 @@ test("a completion beyond the old 20-second deadline produces three validated al
expect(response!.status).toBe(200);
const batch = await response!.json();
expect(batch.alternatives).toHaveLength(3);
expect(batch.provenance.provider).toBe("APIMart");
expect(batch.provenance.provider).toBe("AI");
expect(JSON.stringify(system)).toBe(original);
}, 30_000);
@@ -186,9 +188,9 @@ test("same-origin checks, body bounds and local fallback remain enforced", async
'<head><script type="module"></script></head><div id="diagnostics"></div>',
false,
),
).toContain("APIMART_API_KEY");
).not.toMatch(/APIMART|gpt-/i);
expect(addStudioGuide('<div id="diagnostics"></div>', true)).toContain(
"APIMart AI is enabled",
"AI design assistance is available",
);
});
@@ -225,9 +227,9 @@ test("temporary APIMart failures retry once and return three validated designs",
test("authentication and quota failures provide actionable redacted diagnostics without retrying", async () => {
for (const [status, code, hint] of [
[401, "invalid_api_key", "server API key"],
[402, "insufficient_quota", "credit or quota"],
[404, "model_not_found", "configured model"],
[401, "invalid_api_key", "server configuration issue"],
[402, "insufficient_quota", "service usage limit"],
[404, "model_not_found", "server configuration issue"],
] as const) {
let calls = 0;
const handler = studioAI(
@@ -296,7 +298,7 @@ test("network and repeated rate failures remain bounded and describe the actual
const body = await (await handler(await request()))!.text();
expect(calls).toBe(2);
expect(body).toContain(
network ? "could not reach APIMart" : "rate limiting",
network ? "could not reach the AI service" : "rate limiting",
);
expect(body).not.toContain("test-secret");
}
@@ -421,14 +423,34 @@ test("the configured model is used consistently for metadata and upstream reques
const metadata = await (await handler(
new Request("https://components.example/design-studio/api/generation"),
))!.json();
expect(metadata.model).toBe("custom-model");
expect(metadata.model).toBeUndefined();
const batch = await (await handler(await request()))!.json();
expect(selected).toBe("custom-model");
expect(batch.provenance.model).toBe("custom-model");
expect(
addStudioGuide('<div id="diagnostics"></div>', true, "custom-model"),
).toContain("custom-model");
expect(
addStudioGuide('<div id="diagnostics"></div>', true, "<script>"),
).not.toContain("with <script>");
expect(batch.provenance.model).toBeUndefined();
expect(JSON.stringify(batch)).not.toMatch(/APIMart|gpt-5-mini|custom-model/i);
expect(addStudioGuide('<div id="diagnostics"></div>', true)).not.toMatch(
/APIMart|custom-model|gpt-/i,
);
});
test("all public AI failure categories hide provider and model configuration", async () => {
for (const status of [400, 401, 402, 403, 404, 429, 500]) {
const handler = studioAI(
"test-secret",
mockFetch(
async () =>
new Response(
JSON.stringify({
error: { message: "APIMart custom-secret-model" },
}),
{ status },
),
),
"custom-secret-model",
);
const response = await handler(await request());
expect(await response!.text()).not.toMatch(
/APIMart|custom-secret-model|test-secret/i,
);
}
});