fix(custom-widgets): address review findings
This commit is contained in:
1 parent
349f889464
commit
6cca2bafe6
12 files changed
+158
-21
No files matched your search
@@ -11,7 +11,7 @@ import {
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
|
||||
import { insertCustomWidgetDefinition } from "./definition-insert";
|
||||
import { getPreviewJournal, getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
|
||||
import { getPreviewEvidence, getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
|
||||
import { assertSecretSources } from "./secret-policy";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
@@ -37,7 +37,7 @@ export const creationProcedures = {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a Custom JSX widget without reusing a preview. Prefer customWidget_createFromPreview after full preview validation and query checks so the definition is not streamed again.",
|
||||
"Requires administrator permission. Create a Custom JSX widget without reusing a preview. Prefer customWidget_createFromPreview after full preview validation and query checks so the definition is not streamed again.",
|
||||
},
|
||||
})
|
||||
.input(customWidgetCreateSchema)
|
||||
@@ -56,16 +56,16 @@ export const creationProcedures = {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Persist the exact tested definition from a customWidget_previewCreate or customWidget_previewReviseTemplate session. Prefer this over resending a large widget to customWidget_create. Every query and action in the final preview revision must have current evidence.",
|
||||
"Requires administrator permission. Persist the exact tested definition from a customWidget_previewCreate or customWidget_previewReviseTemplate session. Prefer this over resending a large widget to customWidget_create. Every query and action in the final preview revision must have current evidence.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ previewSessionId: z.string().min(1), targetBoardId: z.string().min(1).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.previewSessionId, ctx.session.user.id);
|
||||
const journal = await getPreviewJournal(session.id, ctx.session.user.id);
|
||||
const evidence = await getPreviewEvidence(session.id, ctx.session.user.id);
|
||||
const unverifiedQueryIds = Object.entries(session.requests).flatMap(([requestId, request]) => {
|
||||
if (request.kind !== "query") return [];
|
||||
const verified = journal.some(
|
||||
const verified = evidence.some(
|
||||
(entry) =>
|
||||
entry.kind === "query" &&
|
||||
entry.requestId === requestId &&
|
||||
@@ -84,12 +84,8 @@ export const creationProcedures = {
|
||||
}
|
||||
const unverifiedActionIds = Object.entries(session.requests).flatMap(([requestId, request]) => {
|
||||
if (request.kind !== "action") return [];
|
||||
const verified = journal.some((entry) => {
|
||||
if (
|
||||
entry.kind !== "action" ||
|
||||
entry.requestId !== requestId ||
|
||||
entry.sessionRevision !== session.revision
|
||||
) {
|
||||
const verified = evidence.some((entry) => {
|
||||
if (entry.kind !== "action" || entry.requestId !== requestId || entry.sessionRevision !== session.revision) {
|
||||
return false;
|
||||
}
|
||||
if (entry.simulated) return true;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { toTrpcError } from "./domain-error";
|
||||
|
||||
const SESSION_PREFIX = "custom-widget:preview-session:";
|
||||
const JOURNAL_PREFIX = "custom-widget:preview-journal:";
|
||||
const EVIDENCE_PREFIX = "custom-widget:preview-evidence:";
|
||||
const COMPARE_AND_SWAP_SESSION_SCRIPT = `
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if not current then return 0 end
|
||||
@@ -64,6 +65,13 @@ class RedisPreviewSessionStore implements PreviewSessionStore {
|
||||
public getJournal(id: string, maxEntries: number) {
|
||||
return this.redis.lrange(`${JOURNAL_PREFIX}${id}`, 0, maxEntries - 1);
|
||||
}
|
||||
public async saveEvidence(id: string, key: string, value: unknown, ttlMs: number) {
|
||||
const evidenceKey = `${EVIDENCE_PREFIX}${id}`;
|
||||
await this.redis.multi().hset(evidenceKey, key, JSON.stringify(value)).pexpire(evidenceKey, ttlMs).exec();
|
||||
}
|
||||
public getEvidence(id: string) {
|
||||
return this.redis.hvals(`${EVIDENCE_PREFIX}${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
let service: CustomWidgetPreviewSessionService | undefined;
|
||||
@@ -104,6 +112,7 @@ export const appendPreviewJournal = (
|
||||
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "sessionRevision" | "timestamp">,
|
||||
) => call(() => getService().appendJournal(session, { ...entry, sessionRevision: session.revision }));
|
||||
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
|
||||
export const getPreviewEvidence = (id: string, userId: string) => call(() => getService().getEvidence(id, userId));
|
||||
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession, sourceId: string) =>
|
||||
getService().getSecrets(session, sourceId);
|
||||
export const setPreviewSessionSecrets = (id: string, userId: string, secrets: CreatePreviewSessionInput["secrets"]) =>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secr
|
||||
import {
|
||||
appendPreviewJournal,
|
||||
configurePreviewSessionSource,
|
||||
getPreviewJournal,
|
||||
getPreviewSession,
|
||||
} from "../../custom-widget/preview-sessions";
|
||||
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
|
||||
@@ -294,6 +295,43 @@ describe("custom widget definition persistence", () => {
|
||||
await expect(caller.get({ id: created.id })).resolves.toMatchObject({ template: revisedTemplate });
|
||||
});
|
||||
|
||||
test("retains persistence evidence for every request beyond the bounded journal", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const requests = Object.fromEntries(
|
||||
Array.from({ length: 64 }, (_, index) => [`query-${index}`, { path: `/queries/${index}` }]),
|
||||
);
|
||||
const definition = customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Large request set",
|
||||
sources: {
|
||||
default: { baseUrl: "https://example.com", networkScope: "public", auth: "none" },
|
||||
},
|
||||
requests,
|
||||
options: {},
|
||||
template: "<Text>All requests tested</Text>",
|
||||
});
|
||||
const preview = await caller.previewCreate({ definition, secrets: [] });
|
||||
const previewSession = await getPreviewSession(preview.previewSession.id, userId);
|
||||
|
||||
for (const [requestId, request] of Object.entries(previewSession.requests)) {
|
||||
await appendPreviewJournal(previewSession, {
|
||||
requestId,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
|
||||
expect(await getPreviewJournal(previewSession.id, userId)).toHaveLength(50);
|
||||
await expect(caller.createFromPreview({ previewSessionId: previewSession.id })).resolves.toMatchObject({
|
||||
managementPath: expect.stringContaining("/manage/custom-widgets/edit/"),
|
||||
});
|
||||
});
|
||||
|
||||
test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
|
||||
@@ -205,6 +205,17 @@ test("MCP tools are deterministically ordered and retain executable schemas", ()
|
||||
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
|
||||
});
|
||||
|
||||
test("publishes both Custom Widget template input formats", () => {
|
||||
const tool = extractMcpToolsFromProcedures(mcpRouter).find(
|
||||
(candidate) => candidate.name === "customWidget_validateTemplate",
|
||||
);
|
||||
|
||||
expect(tool?.inputSchema.properties).toMatchObject({
|
||||
template: expect.any(Object),
|
||||
templateLines: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom widget authoring procedure access", () => {
|
||||
const unauthenticatedCaller = mcpRouter.createCaller({
|
||||
db: null as never,
|
||||
|
||||
Reference in new issue
Block a user