fix: address CodeRabbit review comments
API / router (packages/api):
- open-webui: expose serializable procedures as MCP tools via .meta({ mcp })
and gate knowledge/files/notes reads behind protectedProcedure + interact so
anonymous board viewers can't enumerate private source content
- integration create: wrap integration + creator-permission + secret writes in
a single cross-driver transaction so a partial failure can't leave an
unusable integration
- integration test: scope the permission lookup to the created integration
Integration (packages/integrations):
- open-webui: log only safe metadata (not raw completion chunks) on parse
failure, flush the final buffered SSE frame before treating the stream as
truncated, and apply a default request timeout (streaming opts out)
Widget (packages/widgets):
- collapse knowledge rows by default to avoid an N+1 getKnowledgeFiles burst
- strip markdown images from assistant output to block untrusted fetches
- reconcile the selected model when the model list changes
- clear draft attachments when switching to a saved chat
- dedupe async note/chat attaches after the fetch resolves
- discard stale persist responses via a session token so a cleared chat can't
be rebound to an old Open WebUI chat id
- release the mic stream on mid-start failures and mark the recording cancelled
on unmount so partial audio isn't transcribed
This commit is contained in:
9 files changed
+253
-71
No files matched your search
@@ -345,32 +345,66 @@ export const integrationRouter = createTRPCRouter({
|
||||
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
|
||||
|
||||
const integrationId = createId();
|
||||
await ctx.db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
// Auto-grant the creator "full" permission so they can immediately use
|
||||
// and manage the integration they just created.
|
||||
await ctx.db.insert(integrationUserPermissions).values({
|
||||
const userId = ctx.session.user.id;
|
||||
const secretValues = input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
integrationId,
|
||||
userId: ctx.session.user.id,
|
||||
permission: "full",
|
||||
});
|
||||
}));
|
||||
|
||||
if (input.secrets.length >= 1) {
|
||||
await ctx.db.insert(integrationSecrets).values(
|
||||
input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
integrationId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
// Create the integration, its creator permission and secrets atomically so
|
||||
// a failure part-way through can't leave a committed integration the
|
||||
// creator can neither use nor manage.
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
handleAsync: async (db, schema) => {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
creatorId: userId,
|
||||
});
|
||||
// Auto-grant the creator "full" permission so they can immediately
|
||||
// use and manage the integration they just created.
|
||||
await transaction.insert(schema.integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId,
|
||||
permission: "full",
|
||||
});
|
||||
if (secretValues.length >= 1) {
|
||||
await transaction.insert(schema.integrationSecrets).values(secretValues);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync: (db) => {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.insert(integrations)
|
||||
.values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
creatorId: userId,
|
||||
})
|
||||
.run();
|
||||
transaction
|
||||
.insert(integrationUserPermissions)
|
||||
.values({
|
||||
integrationId,
|
||||
userId,
|
||||
permission: "full",
|
||||
})
|
||||
.run();
|
||||
if (secretValues.length >= 1) {
|
||||
transaction.insert(integrationSecrets).values(secretValues).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Created integration", {
|
||||
id: integrationId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
@@ -321,7 +322,12 @@ describe("create should create a new integration", () => {
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
const dbSecret = await db.query.integrationSecrets.findFirst();
|
||||
const dbPermission = await db.query.integrationUserPermissions.findFirst();
|
||||
const dbPermission = await db.query.integrationUserPermissions.findFirst({
|
||||
where: and(
|
||||
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
|
||||
eq(integrationUserPermissions.userId, defaultUserId),
|
||||
),
|
||||
});
|
||||
expect(dbIntegration).toBeDefined();
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
|
||||
@@ -122,16 +122,30 @@ const groundMessagesAsync = async (
|
||||
};
|
||||
|
||||
export const openWebUiRouter = createTRPCRouter({
|
||||
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
getModels: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "List the available Open WebUI chat models for the selected integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
|
||||
// Chat history exposes private user data, so we require "interact" access
|
||||
// (not just "query") and verify the caller is the integration's creator.
|
||||
getChats: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "List the saved Open WebUI chat conversations for the selected integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
@@ -140,42 +154,81 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
return data;
|
||||
}),
|
||||
|
||||
getKnowledge: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
// Knowledge bases, files and notes expose private source content/metadata, so
|
||||
// gate them like the interactive chat paths (protected + "interact" access,
|
||||
// creator-only) instead of leaving them reachable by anonymous board viewers.
|
||||
getKnowledge: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "List the Open WebUI knowledge bases available for the selected integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
|
||||
getFiles: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
getFiles: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "List the files stored in Open WebUI for the selected integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
|
||||
getNotes: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
getNotes: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "List the notes stored in Open WebUI for the selected integration.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
|
||||
getNote: publicProcedure
|
||||
getNote: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Get the full contents of a single Open WebUI note by id.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ noteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
|
||||
getKnowledgeFiles: publicProcedure
|
||||
getKnowledgeFiles: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "List the files belonging to an Open WebUI knowledge base.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ knowledgeId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
|
||||
knowledgeId: input.knowledgeId,
|
||||
});
|
||||
@@ -186,6 +239,12 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
// Ingest a web page so its content can ground later messages. Returns the
|
||||
// collection to retrieve from plus a display title.
|
||||
processWeb: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Ingest a web page into Open WebUI so its content can ground later chat messages.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ url: z.string().url() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
Reference in new issue
Block a user