feat(custom-widgets): full Mantine v9 + SubFetch support for custom JSX

Massively expands the custom JSX widget system to support virtually all
Mantine v9 components and adds in-widget HTTP request capabilities via
a server-proxied SubFetch system.

- Whitelist ~100 Mantine components (core, all 16 charts, dates)
- Add SubFetch, SubData, ActionButton, ToggleSwitch, RefreshButton
- Add server-side subFetch tRPC mutation with same-origin + auth
- Show HTTP method warning badges in widget and admin preview
- Remove `fetch` from forbidden keywords, increase template to 50KB
- Expand bindings: Date.*, parseInt, parseFloat, Array.from, etc.
- Update AI prompt with full component reference + demo templates
This commit is contained in:
Thomas Camlong committed 2026-07-12 00:42:31 +02:00
1 parent 576856d634
commit aa8410d326
12 files changed
+1164 -257

No files matched your search

@@ -13,9 +13,37 @@ import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../c
const logger = createLogger({ module: "widget:customApi" });
const FETCH_TIMEOUT_MS = 10_000;
const MAX_BODY_SIZE = 10_000;
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
const validateUrl = (urlString: string): URL => new URL(urlString);
const resolveTargetUrl = (definitionUrl: string, targetUrl: string): URL => new URL(targetUrl, validateUrl(definitionUrl));
const assertSameOrigin = (definitionUrl: string, targetUrl: URL): void => {
const definitionOrigin = validateUrl(definitionUrl).origin;
if (targetUrl.origin !== definitionOrigin) {
throw new TRPCError({
code: "FORBIDDEN",
message: "URL must be on the same domain as the widget definition",
});
}
};
const parseResponseData = async (response: Response): Promise<unknown> => {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return response.json();
}
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return text;
}
};
export const customApiRouter = createTRPCRouter({
getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -88,4 +116,97 @@ export const customApiRouter = createTRPCRouter({
clearTimeout(timeout);
}
}),
subFetch: protectedProcedure
.meta({
mcp: {
enabled: true,
description:
"Proxy an HTTP request from a custom JSX widget to an external API on the same domain as the widget definition. Applies the definition's auth credentials. Requires definitionId from the widget configuration.",
},
})
.input(
z.object({
definitionId: z.string(),
url: z.string().min(1).max(2048),
method: z.enum(HTTP_METHODS).default("GET"),
body: z.string().max(MAX_BODY_SIZE).optional(),
headers: z.record(z.string(), z.string()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
if (input.body && input.body.length > MAX_BODY_SIZE) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Request body exceeds 10KB limit" });
}
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
if (!definition.enabled) {
throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
}
const decryptedSecrets = definition.secrets.map((s) => ({
kind: s.kind,
value: decryptSecret(s.value),
}));
const targetUrl = resolveTargetUrl(definition.url, input.url);
assertSameOrigin(definition.url, targetUrl);
const headers = new Headers({ Accept: "application/json" });
if (input.headers) {
for (const [key, value] of Object.entries(input.headers)) {
headers.set(key, value);
}
}
if (input.method !== "GET" && input.body) {
headers.set("Content-Type", "application/json");
}
applyAuth(headers, targetUrl, definition.authType, decryptedSecrets, definition.headerName);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(targetUrl.toString(), {
method: input.method,
headers,
body: input.method !== "GET" ? input.body : undefined,
redirect: "follow",
signal: controller.signal,
});
const data = await parseResponseData(response);
if (!response.ok) {
return {
ok: false as const,
status: response.status,
data,
error: `HTTP ${response.status}: ${response.statusText}`,
};
}
return { ok: true as const, status: response.status, data };
} catch (error) {
logger.error("SubFetch proxy request failed", { definitionId: input.definitionId, url: input.url, error });
return {
ok: false as const,
status: 0,
data: null,
error: error instanceof Error ? error.message : "Request failed",
};
} finally {
clearTimeout(timeout);
}
}),
});