From 9ed28548d9b09921ddc840700ca77dd84b7b84b4 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Thu, 20 Aug 2026 15:19:23 -0300
Subject: [PATCH 01/34] Take out the Drive connector that answered as the
deployment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The knowledge lane needs answers that come back as the person asking. What was
in the tree did the opposite: `connectors.ts` configured Google Drive with a
service account and a domain-impersonation subject, and the worker synced
documents into a local pgvector index guarded by our own ACL rows. Every person
got the same answer, computed from what one credential could see, and revoking
somebody's access left a cached copy of their documents behind.
Fixing it is not a change to that code, it is the other design, so this takes it
out first rather than building the replacement beside it. Nothing imported any of
it outside its own tests, so nothing observable goes with it except two admin
screens that configured a sync that will not happen.
Gone: the in-memory knowledge repository and its ACL check, the knowledge agent,
the connector catalogue and admin service, the sync persistence and the worker's
connector runner, the `/api/admin/connectors` routes and the two admin screens in
front of them.
Kept on purpose, both recorded where a reader will meet them:
- `knowledge.yaml` is still parsed and still validated. It is part of the
deployment-package contract and shipped packages carry it, so a malformed one
should keep being refused. `knowledgeSources` is now read by nothing, and
says so, so the next reader does not take it for something live.
- Every table stays. Dropping `connector_instances`, `documents`, `chunks`,
`document_acls` and the rest is an irreversible migration with none of this
slice's purpose behind it, and the `chunks` embedding column may yet be
wanted. They are unused tables until somebody decides otherwise.
Two things worth knowing for the next change here. `createApp` lost a positional
parameter, so the placeholder runs in the agent and channel route tests each drop
one `undefined`; both tests assert through the store they pass, which is what
catches a slot landing in the wrong position, since consecutive `undefined`s
shift without a type error. And `routeTree.gen.ts` carries `@ts-nocheck`, so
`bun run typecheck` stayed green while the route tree still imported both deleted
screens — the build is what catches that, not the typechecker.
The worker is now a stub that reports idle. Left in place; removing a workspace
is a separate decision.
---
app/src/components/admin/admin-sidebar.tsx | 10 +-
app/src/lib/connectors/queries.ts | 28 ----
app/src/routeTree.gen.ts | 54 -------
app/src/routes/_authed/admin/connectors.tsx | 89 -----------
.../_authed/admin/connectors/google-drive.tsx | 143 ------------------
app/src/routes/_authed/admin/index.tsx | 7 -
app/tests/connectors.test.ts | 6 -
server/src/agents/knowledge-agent.ts | 22 ---
server/src/app.ts | 59 --------
server/src/connectors.ts | 124 ---------------
server/src/connectors/contract.ts | 27 ----
server/src/connectors/sync-persistence.ts | 107 -------------
server/src/index.ts | 10 --
server/src/knowledge/acl.ts | 22 ---
server/src/knowledge/repository.ts | 57 -------
server/src/knowledge/types.ts | 9 --
server/src/tenant-package.ts | 10 ++
server/tests/agent-routes.test.ts | 5 +-
server/tests/channel-routes.test.ts | 3 +-
.../tests/connector-admin.integration.test.ts | 109 -------------
server/tests/connectors.test.ts | 135 -----------------
server/tests/knowledge-acl.test.ts | 30 ----
server/tests/knowledge-agent.test.ts | 37 -----
server/tests/knowledge-repository.test.ts | 51 -------
.../sync-persistence.integration.test.ts | 97 ------------
worker/src/connector-runner.ts | 45 ------
worker/tests/connector-runner.test.ts | 77 ----------
worker/tests/status.test.ts | 2 +-
28 files changed, 16 insertions(+), 1359 deletions(-)
delete mode 100644 app/src/lib/connectors/queries.ts
delete mode 100644 app/src/routes/_authed/admin/connectors.tsx
delete mode 100644 app/src/routes/_authed/admin/connectors/google-drive.tsx
delete mode 100644 app/tests/connectors.test.ts
delete mode 100644 server/src/agents/knowledge-agent.ts
delete mode 100644 server/src/connectors.ts
delete mode 100644 server/src/connectors/contract.ts
delete mode 100644 server/src/connectors/sync-persistence.ts
delete mode 100644 server/src/knowledge/acl.ts
delete mode 100644 server/src/knowledge/repository.ts
delete mode 100644 server/src/knowledge/types.ts
delete mode 100644 server/tests/connector-admin.integration.test.ts
delete mode 100644 server/tests/connectors.test.ts
delete mode 100644 server/tests/knowledge-acl.test.ts
delete mode 100644 server/tests/knowledge-agent.test.ts
delete mode 100644 server/tests/knowledge-repository.test.ts
delete mode 100644 server/tests/sync-persistence.integration.test.ts
delete mode 100644 worker/src/connector-runner.ts
delete mode 100644 worker/tests/connector-runner.test.ts
diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx
index 1a5559e9..a2813876 100644
--- a/app/src/components/admin/admin-sidebar.tsx
+++ b/app/src/components/admin/admin-sidebar.tsx
@@ -5,7 +5,6 @@ import {
IconKey,
IconLayoutGrid,
IconListDetails,
- IconPlugConnected,
IconPuzzle,
IconShieldCheck,
} from "@tabler/icons-react";
@@ -29,9 +28,9 @@ const adminLinkOptions = { to: "/admin" } satisfies LinkOptions;
/**
* The same three groups, in the same order, as the admin index.
*
- * A rail that lists eight things flat asks somebody to know which of them is the one they want. The
+ * A rail that lists seven things flat asks somebody to know which of them is the one they want. The
* grouping is the only navigation help this screen offers, so it has to agree with the page it
- * navigates to — two different orderings of the same eight links is worse than either ordering.
+ * navigates to — two different orderings of the same seven links is worse than either ordering.
*/
const GROUPS: {
label: string;
@@ -44,11 +43,6 @@ const GROUPS: {
{
label: "What Bots can reach",
items: [
- {
- title: "Connectors",
- icon: IconPlugConnected,
- linkOptions: { to: "/admin/connectors" },
- },
{
title: "Credentials",
icon: IconKey,
diff --git a/app/src/lib/connectors/queries.ts b/app/src/lib/connectors/queries.ts
deleted file mode 100644
index ac0e08f3..00000000
--- a/app/src/lib/connectors/queries.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { queryOptions } from "@tanstack/react-query";
-
-export type ConnectorStatus = {
- id: string;
- type: "google_drive" | "onedrive";
- name: string;
- roots: string[];
- configured: boolean;
-};
-
-export const connectorKeys = {
- all: ["connectors"] as const,
- list: () => [...connectorKeys.all, "list"] as const,
-};
-
-export function connectorListQueryOptions() {
- return queryOptions({
- queryKey: connectorKeys.list(),
- queryFn: async (): Promise => {
- const response = await fetch("/api/admin/connectors", {
- credentials: "include",
- });
- if (!response.ok) throw new Error("Could not load connectors");
- return ((await response.json()) as { connectors: ConnectorStatus[] })
- .connectors;
- },
- });
-}
diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts
index 3feb12cc..78ca426e 100644
--- a/app/src/routeTree.gen.ts
+++ b/app/src/routeTree.gen.ts
@@ -22,7 +22,6 @@ import { Route as AuthedAdminAuditRouteImport } from './routes/_authed/admin/aud
import { Route as AuthedAdminBoundariesRouteImport } from './routes/_authed/admin/boundaries'
import { Route as AuthedAdminComponentsRouteImport } from './routes/_authed/admin/components'
import { Route as AuthedAdminComputersRouteImport } from './routes/_authed/admin/computers'
-import { Route as AuthedAdminConnectorsRouteImport } from './routes/_authed/admin/connectors'
import { Route as AuthedAdminCredentialsRouteImport } from './routes/_authed/admin/credentials'
import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admin/playground'
import { Route as AuthedAdminPluginsRouteImport } from './routes/_authed/admin/plugins'
@@ -30,7 +29,6 @@ import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settin
import { Route as AuthedAppAgentsIndexRouteImport } from './routes/_authed/_app/agents/index'
import { Route as AuthedAppChannelChannelIdRouteImport } from './routes/_authed/_app/channel/$channelId'
import { Route as AuthedAppChannelNewRouteImport } from './routes/_authed/_app/channel/new'
-import { Route as AuthedAdminConnectorsGoogleDriveRouteImport } from './routes/_authed/admin/connectors/google-drive'
const AuthedRoute = AuthedRouteImport.update({
id: '/_authed',
@@ -95,11 +93,6 @@ const AuthedAdminComputersRoute = AuthedAdminComputersRouteImport.update({
path: '/computers',
getParentRoute: () => AuthedAdminRouteRoute,
} as any)
-const AuthedAdminConnectorsRoute = AuthedAdminConnectorsRouteImport.update({
- id: '/connectors',
- path: '/connectors',
- getParentRoute: () => AuthedAdminRouteRoute,
-} as any)
const AuthedAdminCredentialsRoute = AuthedAdminCredentialsRouteImport.update({
id: '/credentials',
path: '/credentials',
@@ -136,12 +129,6 @@ const AuthedAppChannelNewRoute = AuthedAppChannelNewRouteImport.update({
path: '/channel/new',
getParentRoute: () => AuthedAppRoute,
} as any)
-const AuthedAdminConnectorsGoogleDriveRoute =
- AuthedAdminConnectorsGoogleDriveRouteImport.update({
- id: '/google-drive',
- path: '/google-drive',
- getParentRoute: () => AuthedAdminConnectorsRoute,
- } as any)
export interface FileRoutesByFullPath {
'/': typeof AuthedAppIndexRoute
@@ -154,7 +141,6 @@ export interface FileRoutesByFullPath {
'/admin/boundaries': typeof AuthedAdminBoundariesRoute
'/admin/components': typeof AuthedAdminComponentsRoute
'/admin/computers': typeof AuthedAdminComputersRoute
- '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren
'/admin/credentials': typeof AuthedAdminCredentialsRoute
'/admin/playground': typeof AuthedAdminPlaygroundRoute
'/admin/plugins': typeof AuthedAdminPluginsRoute
@@ -162,7 +148,6 @@ export interface FileRoutesByFullPath {
'/settings/': typeof AuthedSettingsIndexRoute
'/channel/$channelId': typeof AuthedAppChannelChannelIdRoute
'/channel/new': typeof AuthedAppChannelNewRoute
- '/admin/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute
'/agents/': typeof AuthedAppAgentsIndexRoute
}
export interface FileRoutesByTo {
@@ -174,7 +159,6 @@ export interface FileRoutesByTo {
'/admin/boundaries': typeof AuthedAdminBoundariesRoute
'/admin/components': typeof AuthedAdminComponentsRoute
'/admin/computers': typeof AuthedAdminComputersRoute
- '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren
'/admin/credentials': typeof AuthedAdminCredentialsRoute
'/admin/playground': typeof AuthedAdminPlaygroundRoute
'/admin/plugins': typeof AuthedAdminPluginsRoute
@@ -182,7 +166,6 @@ export interface FileRoutesByTo {
'/settings': typeof AuthedSettingsIndexRoute
'/channel/$channelId': typeof AuthedAppChannelChannelIdRoute
'/channel/new': typeof AuthedAppChannelNewRoute
- '/admin/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute
'/agents': typeof AuthedAppAgentsIndexRoute
}
export interface FileRoutesById {
@@ -198,7 +181,6 @@ export interface FileRoutesById {
'/_authed/admin/boundaries': typeof AuthedAdminBoundariesRoute
'/_authed/admin/components': typeof AuthedAdminComponentsRoute
'/_authed/admin/computers': typeof AuthedAdminComputersRoute
- '/_authed/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren
'/_authed/admin/credentials': typeof AuthedAdminCredentialsRoute
'/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute
'/_authed/admin/plugins': typeof AuthedAdminPluginsRoute
@@ -207,7 +189,6 @@ export interface FileRoutesById {
'/_authed/settings/': typeof AuthedSettingsIndexRoute
'/_authed/_app/channel/$channelId': typeof AuthedAppChannelChannelIdRoute
'/_authed/_app/channel/new': typeof AuthedAppChannelNewRoute
- '/_authed/admin/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute
'/_authed/_app/agents/': typeof AuthedAppAgentsIndexRoute
}
export interface FileRouteTypes {
@@ -223,7 +204,6 @@ export interface FileRouteTypes {
| '/admin/boundaries'
| '/admin/components'
| '/admin/computers'
- | '/admin/connectors'
| '/admin/credentials'
| '/admin/playground'
| '/admin/plugins'
@@ -231,7 +211,6 @@ export interface FileRouteTypes {
| '/settings/'
| '/channel/$channelId'
| '/channel/new'
- | '/admin/connectors/google-drive'
| '/agents/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -243,7 +222,6 @@ export interface FileRouteTypes {
| '/admin/boundaries'
| '/admin/components'
| '/admin/computers'
- | '/admin/connectors'
| '/admin/credentials'
| '/admin/playground'
| '/admin/plugins'
@@ -251,7 +229,6 @@ export interface FileRouteTypes {
| '/settings'
| '/channel/$channelId'
| '/channel/new'
- | '/admin/connectors/google-drive'
| '/agents'
id:
| '__root__'
@@ -266,7 +243,6 @@ export interface FileRouteTypes {
| '/_authed/admin/boundaries'
| '/_authed/admin/components'
| '/_authed/admin/computers'
- | '/_authed/admin/connectors'
| '/_authed/admin/credentials'
| '/_authed/admin/playground'
| '/_authed/admin/plugins'
@@ -275,7 +251,6 @@ export interface FileRouteTypes {
| '/_authed/settings/'
| '/_authed/_app/channel/$channelId'
| '/_authed/_app/channel/new'
- | '/_authed/admin/connectors/google-drive'
| '/_authed/_app/agents/'
fileRoutesById: FileRoutesById
}
@@ -377,13 +352,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminComputersRouteImport
parentRoute: typeof AuthedAdminRouteRoute
}
- '/_authed/admin/connectors': {
- id: '/_authed/admin/connectors'
- path: '/connectors'
- fullPath: '/admin/connectors'
- preLoaderRoute: typeof AuthedAdminConnectorsRouteImport
- parentRoute: typeof AuthedAdminRouteRoute
- }
'/_authed/admin/credentials': {
id: '/_authed/admin/credentials'
path: '/credentials'
@@ -433,35 +401,14 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAppChannelNewRouteImport
parentRoute: typeof AuthedAppRoute
}
- '/_authed/admin/connectors/google-drive': {
- id: '/_authed/admin/connectors/google-drive'
- path: '/google-drive'
- fullPath: '/admin/connectors/google-drive'
- preLoaderRoute: typeof AuthedAdminConnectorsGoogleDriveRouteImport
- parentRoute: typeof AuthedAdminConnectorsRoute
- }
}
}
-interface AuthedAdminConnectorsRouteChildren {
- AuthedAdminConnectorsGoogleDriveRoute: typeof AuthedAdminConnectorsGoogleDriveRoute
-}
-
-const AuthedAdminConnectorsRouteChildren: AuthedAdminConnectorsRouteChildren = {
- AuthedAdminConnectorsGoogleDriveRoute: AuthedAdminConnectorsGoogleDriveRoute,
-}
-
-const AuthedAdminConnectorsRouteWithChildren =
- AuthedAdminConnectorsRoute._addFileChildren(
- AuthedAdminConnectorsRouteChildren,
- )
-
interface AuthedAdminRouteRouteChildren {
AuthedAdminAuditRoute: typeof AuthedAdminAuditRoute
AuthedAdminBoundariesRoute: typeof AuthedAdminBoundariesRoute
AuthedAdminComponentsRoute: typeof AuthedAdminComponentsRoute
AuthedAdminComputersRoute: typeof AuthedAdminComputersRoute
- AuthedAdminConnectorsRoute: typeof AuthedAdminConnectorsRouteWithChildren
AuthedAdminCredentialsRoute: typeof AuthedAdminCredentialsRoute
AuthedAdminPlaygroundRoute: typeof AuthedAdminPlaygroundRoute
AuthedAdminPluginsRoute: typeof AuthedAdminPluginsRoute
@@ -473,7 +420,6 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = {
AuthedAdminBoundariesRoute: AuthedAdminBoundariesRoute,
AuthedAdminComponentsRoute: AuthedAdminComponentsRoute,
AuthedAdminComputersRoute: AuthedAdminComputersRoute,
- AuthedAdminConnectorsRoute: AuthedAdminConnectorsRouteWithChildren,
AuthedAdminCredentialsRoute: AuthedAdminCredentialsRoute,
AuthedAdminPlaygroundRoute: AuthedAdminPlaygroundRoute,
AuthedAdminPluginsRoute: AuthedAdminPluginsRoute,
diff --git a/app/src/routes/_authed/admin/connectors.tsx b/app/src/routes/_authed/admin/connectors.tsx
deleted file mode 100644
index dd699db5..00000000
--- a/app/src/routes/_authed/admin/connectors.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { IconBrandGoogleDrive, IconCloud } from "@tabler/icons-react";
-import { useQuery } from "@tanstack/react-query";
-import { createFileRoute, Link } from "@tanstack/react-router";
-import {
- PageEmpty,
- PageRows,
- PageSection,
- PageShell,
-} from "@/components/layout/page-shell";
-import { StaggerItem } from "@/components/layout/stagger";
-import { Button } from "@/components/ui/button";
-import {
- Item,
- ItemActions,
- ItemContent,
- ItemDescription,
- ItemMedia,
- ItemTitle,
-} from "@/components/ui/item";
-import { Separator } from "@/components/ui/separator";
-import { connectorListQueryOptions } from "@/lib/connectors/queries";
-
-export const Route = createFileRoute("/_authed/admin/connectors")({
- component: ConnectorsPage,
-});
-
-function ConnectorsPage() {
- const connectors = useQuery(connectorListQueryOptions());
- return (
-
-
- {connectors.isPending ? (
- Loading connectors…
- ) : connectors.error ? (
-
- Could not load connectors.
-
- ) : connectors.data?.length === 0 ? (
-
- No connectors. They come from this deployment's knowledge sources.
-
- ) : (
-
- {connectors.data?.map((connector, index) => (
-
-
-
- {connector.type === "google_drive" ? (
-
- ) : (
-
- )}
-
-
- {connector.name}
-
- Roots: {connector.roots.join(", ")} ·{" "}
- {connector.configured ? "Configured" : "Not configured"}
-
-
-
- {connector.type === "google_drive" ? (
- }
- size="sm"
- variant="outline"
- >
- Set up
-
- ) : (
- // Said rather than left blank, which would read as a control yet to arrive.
-
- No setup screen yet
-
- )}
-
-
- {index !== (connectors.data?.length ?? 0) - 1 && }
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/app/src/routes/_authed/admin/connectors/google-drive.tsx b/app/src/routes/_authed/admin/connectors/google-drive.tsx
deleted file mode 100644
index f2d79ebf..00000000
--- a/app/src/routes/_authed/admin/connectors/google-drive.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useForm } from "@tanstack/react-form";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { createFileRoute } from "@tanstack/react-router";
-import { z } from "zod";
-import { PageShell } from "@/components/layout/page-shell";
-import { Button } from "@/components/ui/button";
-import {
- Field,
- FieldError,
- FieldGroup,
- FieldLabel,
-} from "@/components/ui/field";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { connectorKeys } from "@/lib/connectors/queries";
-
-export const Route = createFileRoute("/_authed/admin/connectors/google-drive")({
- component: GoogleDriveConnectorPage,
-});
-
-function GoogleDriveConnectorPage() {
- const queryClient = useQueryClient();
- const setup = useMutation({
- mutationFn: async (value: {
- serviceAccountJson: string;
- impersonationSubject: string;
- }) => {
- const response = await fetch("/api/admin/connectors/google-drive/setup", {
- method: "POST",
- credentials: "include",
- headers: { "content-type": "application/json" },
- body: JSON.stringify(value),
- });
- if (!response.ok) throw new Error("Could not set up Google Drive");
- },
- onSuccess: () =>
- queryClient.invalidateQueries({ queryKey: connectorKeys.all }),
- });
- const form = useForm({
- defaultValues: { serviceAccountJson: "", impersonationSubject: "" },
- validators: {
- onSubmit: z.object({
- serviceAccountJson: z
- .string()
- .trim()
- .refine((value) => {
- try {
- const parsed: unknown = JSON.parse(value);
- return Boolean(
- parsed && typeof parsed === "object" && !Array.isArray(parsed),
- );
- } catch {
- return false;
- }
- }, "Paste a valid service-account JSON object."),
- impersonationSubject: z
- .string()
- .email("Enter the Workspace account to impersonate."),
- }),
- },
- onSubmit: async ({ value }) => {
- await setup.mutateAsync(value);
- form.reset();
- },
- });
- return (
- /*
- * THE FORM STAYS ON THE PAGE HERE, unlike the rest of admin. This route exists only to hold it —
- * there is no list behind it to interrupt — so putting it in a dialog would mean navigating to a
- * page whose only content immediately covers itself up.
- */
-
-
-
- );
-}
diff --git a/app/src/routes/_authed/admin/index.tsx b/app/src/routes/_authed/admin/index.tsx
index ed7f4886..89c72de0 100644
--- a/app/src/routes/_authed/admin/index.tsx
+++ b/app/src/routes/_authed/admin/index.tsx
@@ -5,7 +5,6 @@ import {
IconKey,
IconLayoutGrid,
IconListDetails,
- IconPlugConnected,
IconPuzzle,
IconShieldCheck,
} from "@tabler/icons-react";
@@ -54,12 +53,6 @@ const SECTIONS: {
description:
"Everything a Bot can touch outside this app, and the limits on it.",
items: [
- {
- title: "Connectors",
- description: "The services Bots can read from, and who connected them.",
- icon: IconPlugConnected,
- linkOptions: { to: "/admin/connectors" },
- },
{
title: "Credentials",
description: "Keys and tokens held for this deployment.",
diff --git a/app/tests/connectors.test.ts b/app/tests/connectors.test.ts
deleted file mode 100644
index f7dd3af3..00000000
--- a/app/tests/connectors.test.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { expect, test } from "bun:test";
-import { connectorKeys } from "@/lib/connectors/queries";
-
-test("uses a dedicated query namespace for the seeded connector catalog", () => {
- expect(connectorKeys.list()).toEqual(["connectors", "list"]);
-});
diff --git a/server/src/agents/knowledge-agent.ts b/server/src/agents/knowledge-agent.ts
deleted file mode 100644
index 007b006c..00000000
--- a/server/src/agents/knowledge-agent.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-type Citation = { title: string; canonicalUrl: string; content: string };
-
-export function createKnowledgeAgent(input: {
- available: boolean;
- search: (question: string) => Promise;
- complete: (input: {
- question: string;
- context: Citation[];
- }) => Promise;
-}) {
- return {
- async respond(question: string) {
- if (!input.available)
- throw new Error("Model credential is not configured.");
- const citations = await input.search(question);
- return {
- text: await input.complete({ question, context: citations }),
- citations,
- };
- },
- };
-}
diff --git a/server/src/app.ts b/server/src/app.ts
index 35ac76d3..e97946b1 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -25,7 +25,6 @@ import type { PolicyStore } from "./computer/policy-store";
import { createComputerRoutes } from "./computer/routes";
import { authoriseAgentCall } from "./agents/callback-token";
import type { DeploymentConfig } from "./config";
-import type { ConnectorAdminService } from "./connectors";
import type { CredentialAdminService, CredentialInput } from "./credentials";
import { createPluginRoutes } from "./plugins/routes";
import { REFUSAL_MARKER } from "./plugins/tools";
@@ -39,7 +38,6 @@ export function createApp(
auditReader?: AuditReader,
credentialService?: CredentialAdminService,
packageStatusReader?: PackageStatusReader,
- connectorService?: ConnectorAdminService,
/**
* The CopilotKit endpoint, already built by the caller.
*
@@ -251,41 +249,6 @@ export function createApp(
}
return context.json({ package: await packageStatusReader.active() });
});
- app.get("/api/admin/connectors", requireUser, async (context) => {
- const denied = requireAdmin(context);
- if (denied) return denied;
- if (!connectorService) {
- return context.json(
- { error: "Connector management is not configured." },
- 503,
- );
- }
-
- return context.json({ connectors: await connectorService.list() });
- });
- app.post(
- "/api/admin/connectors/google-drive/setup",
- requireUser,
- async (context) => {
- const denied = requireAdmin(context);
- if (denied) return denied;
- if (!connectorService?.configureGoogleDrive) {
- return context.json(
- { error: "Google Drive setup is not configured." },
- 503,
- );
- }
- const body = await context.req.json().catch(() => null);
- const input = googleDriveSetupInput(body, context.var.actor.id);
- if (!input)
- return context.json({ error: "Google Drive setup is invalid." }, 400);
- return context.json(
- { connector: await connectorService.configureGoogleDrive(input) },
- 201,
- );
- },
- );
-
// The CopilotKit runtime, behind the same session guard as every other API route. Mounted last so
// its own routing under /api/copilotkit cannot shadow an OpenBot route declared above.
if (copilotHandler) {
@@ -429,28 +392,6 @@ export function createApp(
return app;
}
-function googleDriveSetupInput(value: unknown, actorUserId: string) {
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
- const body = value as Record;
- if (
- typeof body.serviceAccountJson !== "string" ||
- typeof body.impersonationSubject !== "string" ||
- !body.impersonationSubject.trim()
- )
- return null;
- try {
- const json = JSON.parse(body.serviceAccountJson) as unknown;
- if (!json || typeof json !== "object" || Array.isArray(json)) return null;
- } catch {
- return null;
- }
- return {
- serviceAccountJson: body.serviceAccountJson,
- impersonationSubject: body.impersonationSubject.trim(),
- actorUserId,
- };
-}
-
function credentialInput(
value: unknown,
actorUserId: string,
diff --git a/server/src/connectors.ts b/server/src/connectors.ts
deleted file mode 100644
index 7ad7f7fa..00000000
--- a/server/src/connectors.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-export type ConnectorStatus = {
- id: string;
- type: "google_drive" | "onedrive";
- name: string;
- roots: string[];
- configured: boolean;
-};
-
-export type ConnectorAdminService = {
- list: () => Promise;
- configureGoogleDrive?: (input: {
- serviceAccountJson: string;
- impersonationSubject: string;
- actorUserId: string;
- }) => Promise;
-};
-
-type KnowledgeSource = {
- type: "google-drive" | "microsoft-onedrive";
- roots: string[];
-};
-
-export function createConnectorCatalogService(
- sources: KnowledgeSource[],
-): ConnectorAdminService {
- return {
- list: async () =>
- sources.map((source) =>
- source.type === "google-drive"
- ? {
- id: "google-drive",
- type: "google_drive",
- name: "Google Drive",
- roots: source.roots,
- configured: false,
- }
- : {
- id: "microsoft-onedrive",
- type: "onedrive",
- name: "Microsoft OneDrive",
- roots: source.roots,
- configured: false,
- },
- ),
- };
-}
-
-export function createConnectorAdminService(
- sources: KnowledgeSource[],
- database: Database,
- credentials: CredentialAdminService,
-): ConnectorAdminService {
- const catalog = createConnectorCatalogService(sources);
- return {
- /**
- * The catalogue, with each entry told whether this deployment has configured it.
- *
- * `knowledge.yaml` says what a deployment may connect to rather than what it has, so whether a
- * connector is configured is read from the instances table instead.
- */
- list: async () => {
- const configured = new Set(
- (
- await database
- .select({ type: connectorInstances.type })
- .from(connectorInstances)
- ).map((row) => row.type),
- );
- return (await catalog.list()).map((connector) => ({
- ...connector,
- configured: configured.has(connector.type),
- }));
- },
- configureGoogleDrive: async (input) => {
- const source = sources.find((item) => item.type === "google-drive");
- if (!source)
- throw new Error("Google Drive is not enabled by knowledge.yaml");
- const credential = await credentials.create({
- kind: "connector",
- provider: "google_drive",
- keyId: input.impersonationSubject,
- metadata: {},
- plaintext: input.serviceAccountJson,
- actorUserId: input.actorUserId,
- });
- const sourceMetadata = {
- roots: source.roots,
- impersonationSubject: input.impersonationSubject,
- };
- const [existing] = await database
- .select({ id: connectorInstances.id })
- .from(connectorInstances)
- .where(eq(connectorInstances.type, "google_drive"));
- if (existing) {
- await database
- .update(connectorInstances)
- .set({
- credentialId: credential.id,
- sourceMetadata,
- updatedAt: new Date(),
- })
- .where(eq(connectorInstances.id, existing.id));
- } else {
- await database.insert(connectorInstances).values({
- type: "google_drive",
- credentialId: credential.id,
- sourceMetadata,
- });
- }
- return {
- id: "google-drive",
- type: "google_drive",
- name: "Google Drive",
- roots: source.roots,
- configured: true,
- };
- },
- };
-}
-
-import { eq } from "drizzle-orm";
-import type { CredentialAdminService } from "./credentials";
-import type { Database } from "./db/client";
-import { connectorInstances } from "./db/schema";
diff --git a/server/src/connectors/contract.ts b/server/src/connectors/contract.ts
deleted file mode 100644
index 350e564c..00000000
--- a/server/src/connectors/contract.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-export type ConnectorUpsert = {
- kind: "upsert";
- sourceId: string;
- title: string;
- canonicalUrl: string;
- contentHash: string;
- metadata: Record;
- chunks: { position: number; content: string; embedding: number[] }[];
- acls: { principal: string; effect: "allow" | "deny" }[];
-};
-
-export type ConnectorDelete = {
- kind: "delete";
- sourceId: string;
-};
-
-export type ConnectorChange = ConnectorUpsert | ConnectorDelete;
-
-export type ConnectorAdapter = {
- discover: (input: {
- cursor: string | null;
- mode: "sync" | "reconcile";
- }) => Promise<{
- changes: ConnectorChange[];
- nextCursor: string | null;
- }>;
-};
diff --git a/server/src/connectors/sync-persistence.ts b/server/src/connectors/sync-persistence.ts
deleted file mode 100644
index b0303526..00000000
--- a/server/src/connectors/sync-persistence.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import { and, eq } from "drizzle-orm";
-import type { Database } from "../db/client";
-import {
- chunks,
- connectorCursors,
- documentAcls,
- documents,
- syncRuns,
-} from "../db/schema";
-import type { ConnectorChange } from "./contract";
-
-export function createSyncPersistence(
- database: Database,
- connectorInstanceId: string,
-) {
- return {
- async persistBatch(changes: ConnectorChange[], cursor: string | null) {
- await database.transaction(async (transaction) => {
- for (const change of changes) {
- if (change.kind === "delete") {
- await transaction
- .update(documents)
- .set({ deletedAt: new Date(), updatedAt: new Date() })
- .where(
- and(
- eq(documents.connectorInstanceId, connectorInstanceId),
- eq(documents.sourceId, change.sourceId),
- ),
- );
- continue;
- }
- const [document] = await transaction
- .insert(documents)
- .values({
- connectorInstanceId,
- sourceId: change.sourceId,
- title: change.title,
- canonicalUrl: change.canonicalUrl,
- metadata: change.metadata,
- contentHash: change.contentHash,
- deletedAt: null,
- updatedAt: new Date(),
- })
- .onConflictDoUpdate({
- target: [documents.connectorInstanceId, documents.sourceId],
- set: {
- title: change.title,
- canonicalUrl: change.canonicalUrl,
- metadata: change.metadata,
- contentHash: change.contentHash,
- deletedAt: null,
- updatedAt: new Date(),
- },
- })
- .returning({ id: documents.id });
- if (!document)
- throw new Error("Document upsert did not return an ID.");
- await transaction
- .delete(chunks)
- .where(eq(chunks.documentId, document.id));
- await transaction
- .delete(documentAcls)
- .where(eq(documentAcls.documentId, document.id));
- if (change.chunks.length) {
- await transaction.insert(chunks).values(
- change.chunks.map((chunk) => ({
- documentId: document.id,
- position: chunk.position,
- content: chunk.content,
- embedding: chunk.embedding,
- })),
- );
- }
- if (change.acls.length) {
- await transaction
- .insert(documentAcls)
- .values(
- change.acls.map((acl) => ({ documentId: document.id, ...acl })),
- );
- }
- }
- await transaction.insert(syncRuns).values({
- connectorInstanceId,
- status: "succeeded",
- completedAt: new Date(),
- stats: { changes: changes.length },
- });
- if (cursor !== null) {
- await transaction
- .insert(connectorCursors)
- .values({ connectorInstanceId, cursor, updatedAt: new Date() })
- .onConflictDoUpdate({
- target: connectorCursors.connectorInstanceId,
- set: { cursor, updatedAt: new Date() },
- });
- }
- });
- },
- async cursor() {
- const [record] = await database
- .select({ cursor: connectorCursors.cursor })
- .from(connectorCursors)
- .where(eq(connectorCursors.connectorInstanceId, connectorInstanceId));
- return record?.cursor ?? null;
- },
- };
-}
diff --git a/server/src/index.ts b/server/src/index.ts
index 9831c3aa..86ef9fac 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -26,7 +26,6 @@ import {
} from "./computer/policy-store";
import { createSupervisorClient } from "./computer/supervisor";
import { loadConfig } from "./config";
-import { createConnectorAdminService } from "./connectors";
import {
type IdentifyActor,
type IdentifyUser,
@@ -309,15 +308,6 @@ const app = createApp(
createAuditStore(database),
),
createPackageStatusReader(database),
- createConnectorAdminService(
- tenantPackage.knowledgeSources,
- database,
- createCredentialAdminService(
- config.keyEncryptionKey,
- credentialStore,
- createAuditStore(database),
- ),
- ),
// The runtime call: the model, per-actor agent loading, and the two identity
// functions are how a run is attributed to a person.
mountCopilotRuntime(
diff --git a/server/src/knowledge/acl.ts b/server/src/knowledge/acl.ts
deleted file mode 100644
index d9dc3e97..00000000
--- a/server/src/knowledge/acl.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { KnowledgeAclEntry, KnowledgeActor } from "./types";
-
-export function canRead(
- actor: KnowledgeActor,
- entries: KnowledgeAclEntry[],
-): boolean {
- let allowed = false;
-
- for (const entry of entries) {
- if (!matchesPrincipal(actor, entry.principal)) continue;
- if (entry.effect === "deny") return false;
- allowed = true;
- }
-
- return allowed;
-}
-
-function matchesPrincipal(actor: KnowledgeActor, principal: string): boolean {
- if (principal === `user:${actor.userId}`) return true;
- if (!principal.startsWith("group:")) return false;
- return actor.groups.includes(principal.slice("group:".length));
-}
diff --git a/server/src/knowledge/repository.ts b/server/src/knowledge/repository.ts
deleted file mode 100644
index 883cf9e3..00000000
--- a/server/src/knowledge/repository.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { canRead } from "./acl";
-import type { KnowledgeAclEntry, KnowledgeActor } from "./types";
-
-type SourceChange = {
- connectorInstanceId: string;
- sourceId: string;
- title: string;
- canonicalUrl: string;
- contentHash: string;
- chunks: { position: number; content: string }[];
- acls: KnowledgeAclEntry[];
-};
-
-type Citation = {
- documentId: string;
- title: string;
- canonicalUrl: string;
- chunkId: string;
- content: string;
-};
-
-export class InMemoryKnowledgeRepository {
- #sources = new Map();
- #deleted = new Set();
-
- apply(change: SourceChange) {
- const key = sourceKey(change.connectorInstanceId, change.sourceId);
- this.#sources.set(key, structuredClone(change));
- this.#deleted.delete(key);
- }
-
- delete(connectorInstanceId: string, sourceId: string) {
- this.#deleted.add(sourceKey(connectorInstanceId, sourceId));
- }
-
- documents(): SourceChange[] {
- return [...this.#sources.values()].map((source) => structuredClone(source));
- }
-
- search(actor: KnowledgeActor): Citation[] {
- return [...this.#sources.entries()].flatMap(([key, source]) => {
- if (this.#deleted.has(key) || !canRead(actor, source.acls)) return [];
- const documentId = key;
- return source.chunks.map((chunk) => ({
- documentId,
- title: source.title,
- canonicalUrl: source.canonicalUrl,
- chunkId: `${documentId}:${chunk.position}`,
- content: chunk.content,
- }));
- });
- }
-}
-
-function sourceKey(connectorInstanceId: string, sourceId: string) {
- return `${connectorInstanceId}:${sourceId}`;
-}
diff --git a/server/src/knowledge/types.ts b/server/src/knowledge/types.ts
deleted file mode 100644
index 5ef9b202..00000000
--- a/server/src/knowledge/types.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export type KnowledgeActor = {
- userId: string;
- groups: string[];
-};
-
-export type KnowledgeAclEntry = {
- principal: string;
- effect: "allow" | "deny";
-};
diff --git a/server/src/tenant-package.ts b/server/src/tenant-package.ts
index 2de9ee81..a49cd632 100644
--- a/server/src/tenant-package.ts
+++ b/server/src/tenant-package.ts
@@ -115,6 +115,16 @@ export type TenantPackage = {
credentialSecretRef: string;
defaultModel: string;
};
+ /**
+ * What `knowledge.yaml` says this deployment may connect to.
+ *
+ * Parsed and validated, and currently read by nothing. The connector that consumed it synced a
+ * customer's Drive into a local index using a service account, so every person's answer came back
+ * as the deployment rather than as themselves; it was removed rather than fixed. The file stays
+ * part of the package contract because shipped packages carry it and validation should keep
+ * refusing a malformed one, but a reader should not take the presence of this field as evidence
+ * that anything acts on it.
+ */
knowledgeSources: {
type: "google-drive" | "microsoft-onedrive";
roots: string[];
diff --git a/server/tests/agent-routes.test.ts b/server/tests/agent-routes.test.ts
index b05774ea..31736328 100644
--- a/server/tests/agent-routes.test.ts
+++ b/server/tests/agent-routes.test.ts
@@ -551,9 +551,8 @@ describe("agent route composition", () => {
api: { getSession: async () => session },
},
{ rolesForUser: async () => ["user"] },
- // Positions 4-11: auditReader, credentialService, packageStatusReader, connectorService,
- // copilotHandler, computerClient, computerGateway, computerPolicy.
- undefined,
+ // Positions 4-10: auditReader, credentialService, packageStatusReader, copilotHandler,
+ // computerClient, computerGateway, computerPolicy.
undefined,
undefined,
undefined,
diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts
index baaf7cf1..7a0d72db 100644
--- a/server/tests/channel-routes.test.ts
+++ b/server/tests/channel-routes.test.ts
@@ -305,7 +305,7 @@ describe("channel route composition", () => {
api: { getSession: async () => session },
},
{ rolesForUser: async () => ["user"] },
- // Positions 4-12, ending at agentProfileStore. the computer gateway and policy store were added
+ // Positions 4-11, ending at agentProfileStore. the computer gateway and policy store were added
// ahead of these, so the run of placeholders grew with them.
undefined,
undefined,
@@ -315,7 +315,6 @@ describe("channel route composition", () => {
undefined,
undefined,
undefined,
- undefined,
store,
);
diff --git a/server/tests/connector-admin.integration.test.ts b/server/tests/connector-admin.integration.test.ts
deleted file mode 100644
index 317756e6..00000000
--- a/server/tests/connector-admin.integration.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { afterAll, beforeAll, expect, test } from "bun:test";
-import { eq } from "drizzle-orm";
-import { createConnectorAdminService } from "../src/connectors";
-import type { CredentialAdminService } from "../src/credentials";
-import { createDatabase } from "../src/db/client";
-import { TEST_POOL } from "./support/database";
-import {
- connectorInstances,
- credentials as credentialRows,
-} from "../src/db/schema";
-
-/**
- * What the Connectors page reports about a connector that has been set up.
- *
- * The catalogue is built from `knowledge.yaml`, which says what a deployment may connect to rather
- * than what it has, so the listing has to read the instances as well.
- */
-
-const database = createDatabase(
- process.env.DATABASE_URL ??
- "postgres://openbot:openbot@localhost:5432/openbot",
- TEST_POOL,
-);
-
-const sources = [
- { type: "google-drive" as const, roots: ["Policies"] },
- { type: "microsoft-onedrive" as const, roots: ["Operations"] },
-];
-
-/**
- * No vault: what is under test is what the listing reports, not how a secret is kept. The row is
- * still written, because a connector instance references the credential it was set up with.
- */
-const issued: string[] = [];
-const credentials = {
- create: async () => {
- const [row] = await database
- .insert(credentialRows)
- .values({
- kind: "connector",
- provider: "google_drive",
- encryptedValue: "{}",
- keyId: "someone@example.com",
- metadata: {},
- })
- .returning({ id: credentialRows.id });
- issued.push(row.id);
- return {
- id: row.id,
- kind: "connector" as const,
- provider: "google_drive",
- keyId: "someone@example.com",
- metadata: {},
- revokedAt: null,
- };
- },
-} as unknown as CredentialAdminService;
-
-const service = createConnectorAdminService(sources, database, credentials);
-
-async function removeGoogleDriveInstance() {
- await database
- .delete(connectorInstances)
- .where(eq(connectorInstances.type, "google_drive"));
-}
-
-let alreadyConfigured = false;
-
-beforeAll(async () => {
- alreadyConfigured =
- (
- await database
- .select({ id: connectorInstances.id })
- .from(connectorInstances)
- .where(eq(connectorInstances.type, "google_drive"))
- ).length > 0;
-});
-
-afterAll(async () => {
- if (!alreadyConfigured) await removeGoogleDriveInstance();
- for (const id of issued) {
- await database.delete(credentialRows).where(eq(credentialRows.id, id));
- }
-});
-
-test("a connector reads as configured once it has been set up", async () => {
- if (alreadyConfigured) await removeGoogleDriveInstance();
-
- const before = await service.list();
- expect(before.map((connector) => connector.configured)).toEqual([
- false,
- false,
- ]);
-
- await service.configureGoogleDrive?.({
- serviceAccountJson: JSON.stringify({ type: "service_account" }),
- impersonationSubject: "someone@example.com",
- actorUserId: "admin",
- });
-
- const after = await service.list();
- // Only the one that was set up, so the listing reports the deployment rather than the catalogue.
- expect(
- after.map((connector) => [connector.type, connector.configured]),
- ).toEqual([
- ["google_drive", true],
- ["onedrive", false],
- ]);
-});
diff --git a/server/tests/connectors.test.ts b/server/tests/connectors.test.ts
deleted file mode 100644
index 81d3eab3..00000000
--- a/server/tests/connectors.test.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { createApp } from "../src/app";
-import { loadConfig } from "../src/config";
-import { createConnectorCatalogService } from "../src/connectors";
-import { testEnvironment } from "./support/environment";
-
-const config = loadConfig(testEnvironment());
-
-describe("admin connectors API", () => {
- test("accepts Google Drive service-account setup without returning the key", async () => {
- const received: unknown[] = [];
- const app = createApp(
- config,
- {
- handler: () => new Response(null, { status: 204 }),
- api: {
- getSession: async () => ({
- user: { id: "admin", email: "admin@openbot.test" },
- }),
- },
- },
- { rolesForUser: async () => ["admin"] },
- undefined,
- undefined,
- undefined,
- {
- list: async () => [],
- configureGoogleDrive: async (input: unknown) => {
- received.push(input);
- return {
- id: "google-drive",
- type: "google_drive" as const,
- name: "Google Drive",
- roots: ["Policies"],
- configured: true,
- };
- },
- },
- );
- const serviceAccountJson = JSON.stringify({
- type: "service_account",
- private_key: "secret",
- });
- const response = await app.request(
- "http://openbot.local/api/admin/connectors/google-drive/setup",
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- serviceAccountJson,
- impersonationSubject: "admin@example.com",
- }),
- },
- );
- expect(response.status).toBe(201);
- expect(await response.text()).not.toContain("secret");
- expect(received).toEqual([
- {
- serviceAccountJson,
- impersonationSubject: "admin@example.com",
- actorUserId: "admin",
- },
- ]);
- });
-
- test("derives the available catalog from deployment knowledge sources", async () => {
- const service = createConnectorCatalogService([
- { type: "google-drive", roots: ["Policies"] },
- { type: "microsoft-onedrive", roots: ["Operations"] },
- ]);
-
- await expect(service.list()).resolves.toEqual([
- {
- id: "google-drive",
- type: "google_drive",
- name: "Google Drive",
- roots: ["Policies"],
- configured: false,
- },
- {
- id: "microsoft-onedrive",
- type: "onedrive",
- name: "Microsoft OneDrive",
- roots: ["Operations"],
- configured: false,
- },
- ]);
- });
-
- test("lists only deployment-seeded connector metadata", async () => {
- const app = createApp(
- config,
- {
- handler: () => new Response(null, { status: 204 }),
- api: {
- getSession: async () => ({
- user: { id: "admin", email: "admin@openbot.test" },
- }),
- },
- },
- { rolesForUser: async () => ["admin"] },
- undefined,
- undefined,
- undefined,
- {
- list: async () => [
- {
- id: "google-drive",
- type: "google_drive" as const,
- name: "Google Drive",
- roots: ["Policies", "Compliance"],
- configured: false,
- },
- ],
- },
- );
-
- const response = await app.request(
- "http://openbot.local/api/admin/connectors",
- );
-
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toEqual({
- connectors: [
- {
- id: "google-drive",
- type: "google_drive",
- name: "Google Drive",
- roots: ["Policies", "Compliance"],
- configured: false,
- },
- ],
- });
- });
-});
diff --git a/server/tests/knowledge-acl.test.ts b/server/tests/knowledge-acl.test.ts
deleted file mode 100644
index e7608922..00000000
--- a/server/tests/knowledge-acl.test.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { canRead } from "../src/knowledge/acl";
-
-describe("knowledge ACL evaluation", () => {
- test("allows a matching user principal", () => {
- expect(
- canRead({ userId: "u1", groups: [] }, [
- { principal: "user:u1", effect: "allow" },
- ]),
- ).toBe(true);
- });
-
- test("fails closed for an unmatched or empty ACL", () => {
- expect(
- canRead({ userId: "u1", groups: ["finance"] }, [
- { principal: "group:engineering", effect: "allow" },
- ]),
- ).toBe(false);
- expect(canRead({ userId: "u1", groups: [] }, [])).toBe(false);
- });
-
- test("makes a matching deny override a matching allow", () => {
- expect(
- canRead({ userId: "u1", groups: ["finance"] }, [
- { principal: "group:finance", effect: "allow" },
- { principal: "user:u1", effect: "deny" },
- ]),
- ).toBe(false);
- });
-});
diff --git a/server/tests/knowledge-agent.test.ts b/server/tests/knowledge-agent.test.ts
deleted file mode 100644
index 53d188a4..00000000
--- a/server/tests/knowledge-agent.test.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { expect, test } from "bun:test";
-import { createKnowledgeAgent } from "../src/agents/knowledge-agent";
-
-test("returns authorized knowledge citations to the model port", async () => {
- const agent = createKnowledgeAgent({
- available: true,
- search: async () => [
- {
- title: "Policy",
- canonicalUrl: "https://example.test/policy",
- content: "Use MFA.",
- },
- ],
- complete: async ({ context }) => `Answer: ${context[0]?.content}`,
- });
- await expect(agent.respond("What is required?")).resolves.toEqual({
- text: "Answer: Use MFA.",
- citations: [
- {
- title: "Policy",
- canonicalUrl: "https://example.test/policy",
- content: "Use MFA.",
- },
- ],
- });
-});
-
-test("refuses to run when its model credential is unavailable", async () => {
- const agent = createKnowledgeAgent({
- available: false,
- search: async () => [],
- complete: async () => "unused",
- });
- await expect(agent.respond("question")).rejects.toThrow(
- "Model credential is not configured.",
- );
-});
diff --git a/server/tests/knowledge-repository.test.ts b/server/tests/knowledge-repository.test.ts
deleted file mode 100644
index d3863a1c..00000000
--- a/server/tests/knowledge-repository.test.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { InMemoryKnowledgeRepository } from "../src/knowledge/repository";
-
-const change = {
- connectorInstanceId: "connector-1",
- sourceId: "source-1",
- title: "Finance policy",
- canonicalUrl: "https://example.test/policy",
- contentHash: "hash-1",
- chunks: [{ position: 0, content: "finance content" }],
- acls: [{ principal: "group:finance", effect: "allow" as const }],
-};
-
-describe("knowledge repository", () => {
- test("replaces source content idempotently", () => {
- const repository = new InMemoryKnowledgeRepository();
- repository.apply(change);
- repository.apply({
- ...change,
- contentHash: "hash-2",
- chunks: [{ position: 0, content: "updated" }],
- });
-
- expect(repository.documents()).toEqual([
- {
- ...change,
- contentHash: "hash-2",
- chunks: [{ position: 0, content: "updated" }],
- },
- ]);
- });
-
- test("returns citations only to authorized actors and hides deleted sources", () => {
- const repository = new InMemoryKnowledgeRepository();
- repository.apply(change);
- expect(repository.search({ userId: "u1", groups: ["finance"] })).toEqual([
- {
- documentId: "connector-1:source-1",
- title: "Finance policy",
- canonicalUrl: "https://example.test/policy",
- chunkId: "connector-1:source-1:0",
- content: "finance content",
- },
- ]);
- expect(repository.search({ userId: "u2", groups: [] })).toEqual([]);
- repository.delete("connector-1", "source-1");
- expect(repository.search({ userId: "u1", groups: ["finance"] })).toEqual(
- [],
- );
- });
-});
diff --git a/server/tests/sync-persistence.integration.test.ts b/server/tests/sync-persistence.integration.test.ts
deleted file mode 100644
index db04500b..00000000
--- a/server/tests/sync-persistence.integration.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { afterEach, describe, expect, test } from "bun:test";
-import { randomUUID } from "node:crypto";
-import { and, eq } from "drizzle-orm";
-import { createSyncPersistence } from "../src/connectors/sync-persistence";
-import { createDatabase } from "../src/db/client";
-import { TEST_POOL } from "./support/database";
-import { connectorInstances, credentials, documents } from "../src/db/schema";
-
-const database = createDatabase(
- process.env.DATABASE_URL ??
- "postgres://openbot:openbot@localhost:5432/openbot",
- TEST_POOL,
-);
-const connectorIds: string[] = [];
-const credentialIds: string[] = [];
-
-afterEach(async () => {
- for (const connectorId of connectorIds.splice(0)) {
- await database
- .delete(connectorInstances)
- .where(eq(connectorInstances.id, connectorId));
- }
- for (const credentialId of credentialIds.splice(0)) {
- await database.delete(credentials).where(eq(credentials.id, credentialId));
- }
-});
-
-async function fixture() {
- const credentialId = randomUUID();
- const connectorId = randomUUID();
- await database.insert(credentials).values({
- id: credentialId,
- kind: "connector",
- provider: "test",
- encryptedValue: "test",
- keyId: "test",
- metadata: {},
- });
- await database.insert(connectorInstances).values({
- id: connectorId,
- type: "google_drive",
- credentialId,
- sourceMetadata: {},
- });
- connectorIds.push(connectorId);
- credentialIds.push(credentialId);
- return { connectorId, credentialId };
-}
-
-describe("sync persistence integration", () => {
- test("replays an upsert without duplicates, then deletes it with its cursor", async () => {
- const { connectorId } = await fixture();
- const persistence = createSyncPersistence(database, connectorId);
- const upsert = {
- kind: "upsert" as const,
- sourceId: "source-1",
- title: "Policy",
- canonicalUrl: "https://example.test/policy",
- contentHash: "v1",
- metadata: {},
- chunks: [
- { position: 0, content: "policy", embedding: Array(1536).fill(0) },
- ],
- acls: [{ principal: "group:finance", effect: "allow" as const }],
- };
-
- await persistence.persistBatch([upsert], "c1");
- await persistence.persistBatch([upsert], "c1");
- const rows = await database
- .select()
- .from(documents)
- .where(
- and(
- eq(documents.connectorInstanceId, connectorId),
- eq(documents.sourceId, "source-1"),
- ),
- );
- expect(rows).toHaveLength(1);
- expect(await persistence.cursor()).toBe("c1");
-
- await persistence.persistBatch(
- [{ kind: "delete", sourceId: "source-1" }],
- "c2",
- );
- const documentId = rows[0]?.id;
- if (!documentId) throw new Error("Expected the upserted document.");
- expect(
- (
- await database
- .select()
- .from(documents)
- .where(eq(documents.id, documentId))
- )[0]?.deletedAt,
- ).toBeInstanceOf(Date);
- expect(await persistence.cursor()).toBe("c2");
- });
-});
diff --git a/worker/src/connector-runner.ts b/worker/src/connector-runner.ts
deleted file mode 100644
index ac941d86..00000000
--- a/worker/src/connector-runner.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import type {
- ConnectorAdapter,
- ConnectorChange,
-} from "../../server/src/connectors/contract";
-
-export type ConnectorPersistence = {
- cursor: () => Promise;
- apply?: (change: ConnectorChange) => Promise;
- commitCursor?: (cursor: string) => Promise;
- persistBatch?: (
- changes: ConnectorChange[],
- cursor: string | null,
- ) => Promise;
- recordRun?: (status: "succeeded" | "failed") => Promise;
-};
-
-export async function runConnector(
- adapter: ConnectorAdapter,
- persistence: ConnectorPersistence,
- mode: "sync" | "reconcile" = "sync",
-) {
- try {
- const discovered = await adapter.discover({
- cursor: await persistence.cursor(),
- mode,
- });
- if (persistence.persistBatch) {
- await persistence.persistBatch(discovered.changes, discovered.nextCursor);
- } else {
- if (!persistence.apply || !persistence.commitCursor) {
- throw new Error(
- "Connector persistence must support batch or individual writes.",
- );
- }
- for (const change of discovered.changes) await persistence.apply(change);
- if (discovered.nextCursor !== null) {
- await persistence.commitCursor(discovered.nextCursor);
- }
- }
- await persistence.recordRun?.("succeeded");
- } catch (error) {
- await persistence.recordRun?.("failed");
- throw error;
- }
-}
diff --git a/worker/tests/connector-runner.test.ts b/worker/tests/connector-runner.test.ts
deleted file mode 100644
index 0c6afdb8..00000000
--- a/worker/tests/connector-runner.test.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { runConnector } from "../src/connector-runner";
-
-describe("connector runner", () => {
- test("commits a cursor only after every discovered change succeeds", async () => {
- const applied: string[] = [];
- const cursors: string[] = [];
-
- await runConnector(
- {
- discover: async () => ({
- changes: [{ kind: "delete", sourceId: "one" }],
- nextCursor: "c1",
- }),
- },
- {
- cursor: async () => null,
- apply: async (change) => void applied.push(change.sourceId),
- commitCursor: async (cursor) => void cursors.push(cursor),
- },
- );
-
- expect(applied).toEqual(["one"]);
- expect(cursors).toEqual(["c1"]);
- });
-
- test("does not advance the cursor when a change fails", async () => {
- const cursors: string[] = [];
-
- await expect(
- runConnector(
- {
- discover: async () => ({
- changes: [{ kind: "delete", sourceId: "one" }],
- nextCursor: "c1",
- }),
- },
- {
- cursor: async () => null,
- apply: async () => {
- throw new Error("write failed");
- },
- commitCursor: async (cursor) => void cursors.push(cursor),
- },
- ),
- ).rejects.toThrow("write failed");
-
- expect(cursors).toEqual([]);
- });
-
- test("passes reconciliation mode to the adapter and records a successful run", async () => {
- const modes: string[] = [];
- const runs: string[] = [];
-
- await runConnector(
- {
- discover: async ({ mode }) => {
- modes.push(mode);
- return {
- changes: [{ kind: "delete", sourceId: "one" }],
- nextCursor: null,
- };
- },
- },
- {
- cursor: async () => "c1",
- apply: async () => undefined,
- commitCursor: async () => undefined,
- recordRun: async (status) => void runs.push(status),
- },
- "reconcile",
- );
-
- expect(modes).toEqual(["reconcile"]);
- expect(runs).toEqual(["succeeded"]);
- });
-});
diff --git a/worker/tests/status.test.ts b/worker/tests/status.test.ts
index bcb031f9..19eb939f 100644
--- a/worker/tests/status.test.ts
+++ b/worker/tests/status.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { workerStatus } from "../src/status";
describe("worker status", () => {
- test("starts idle before connector jobs are configured", () => {
+ test("reports idle, having no jobs to run", () => {
expect(workerStatus()).toEqual({ status: "idle" });
});
});
From 868df4028c8ed5775dd6ac65c3bea9228fe9b411 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Thu, 20 Aug 2026 15:26:58 -0300
Subject: [PATCH 02/34] Make a server say whose credential reaches it, and add
Google Drive
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`needsCredential: boolean` said that a server needed a credential and never said
whose. That is the one thing about a connector worth being unambiguous about: a
reader who has to guess guesses the deployment's, and there was nothing in the
shape to guess against.
It is now `auth`, a discriminated union, and every entry states one:
- `none`, answers without a credential
- `deployment-bearer`, one token an administrator holds for everybody, which is
what all five existing vendors are
- `user-oauth`, the asker's own grant, where the deployment holds only an OAuth
client and each person consents for themselves
Google Drive is the first `user-oauth` entry: `drivemcp.googleapis.com/mcp/v1`,
which is the address Google publishes. It is also the first vendor here that
cannot be reached with a token somebody pastes, because Google issues no such
token — access is an authorization-code grant belonging to a person. That is the
property this connector is for rather than an obstacle to it.
The OAuth authorize, token and revoke addresses are pinned in the entry beside the
MCP host, under the same rule as the host: taken from the vendor's published
documentation, never from a caller. They are where this deployment sends somebody's
authorization code and receives the refresh token standing in for their access, so
they are a reviewed source contract too.
Scope is `drive.readonly` alone. Nothing in this slice writes, and a scope granted
by everybody who connects and used by nothing is a permission nobody remembers
agreeing to. `create_file` and `copy_file` are still listed as writes even though
that scope makes Google refuse them: the scope is what stops them, and the list is
what keeps a boundary written about writes covering them if the scope ever widens.
Drive is one host per Workspace product, so Gmail, Calendar, Chat and the rest are
each a further entry rather than a flag on this one. Adding Gmail stays a reviewed
decision about Gmail.
The Plugins page is sent the kind and not the endpoints. It needs to know what to
ask an administrator for; a URL this deployment sends an authorization code to is
not improved by also existing in every browser that opens that page. The token
field now keys off `deployment-bearer` rather than "needs a credential", so Drive
correctly stops asking for a token it could not use.
Nothing connects yet: adding Drive stores a server with no client behind it, and
calling its tools will refuse until the connect flow lands.
---
app/src/lib/plugins/queries.ts | 9 ++-
app/src/routes/_authed/admin/plugins.tsx | 10 ++-
server/src/plugins/catalogue.ts | 94 ++++++++++++++++++++++--
server/src/plugins/routes.ts | 8 +-
server/tests/plugin-catalogue.test.ts | 71 ++++++++++++++++++
5 files changed, 181 insertions(+), 11 deletions(-)
diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts
index aad4c73a..e2a64e21 100644
--- a/app/src/lib/plugins/queries.ts
+++ b/app/src/lib/plugins/queries.ts
@@ -48,7 +48,14 @@ export type CatalogueItem = {
vendor: string;
summary: string;
docsUrl: string;
- needsCredential: boolean;
+ /**
+ * Whose credential reaches this server.
+ *
+ * `deployment-bearer` is a token an administrator holds for everybody, and the only one this page
+ * can collect. `user-oauth` is reached as whoever is asking, so each person connects their own
+ * account and there is no token to type here.
+ */
+ auth: "none" | "deployment-bearer" | "user-oauth";
/** True for a vendor that gives every customer their own hostname. */
perInstance: boolean;
};
diff --git a/app/src/routes/_authed/admin/plugins.tsx b/app/src/routes/_authed/admin/plugins.tsx
index f805b4a1..ec1e55d2 100644
--- a/app/src/routes/_authed/admin/plugins.tsx
+++ b/app/src/routes/_authed/admin/plugins.tsx
@@ -231,7 +231,13 @@ function Catalogue({
vendor: string;
summary: string;
docsUrl: string;
- needsCredential: boolean;
+ /**
+ * Whose credential reaches this server.
+ *
+ * `deployment-bearer` is the only one an administrator can satisfy by typing a token here.
+ * `user-oauth` is reached as whoever is asking, so there is no token for this page to collect.
+ */
+ auth: "none" | "deployment-bearer" | "user-oauth";
perInstance: boolean;
}[];
added: Set;
@@ -302,7 +308,7 @@ function Catalogue({
value={instanceHost[item.key] ?? ""}
/>
) : null}
- {item.needsCredential ? (
+ {item.auth === "deployment-bearer" ? (
/* Mask tokens before they are stored in the credential vault. */
`, with sandbox orgs under
// `/sandbox/platform/`. A deployment on a sandbox needs the custom-server form.
path: "/platform/mcp/v1/platform/sobject-all",
- needsCredential: true,
+ auth: { kind: "deployment-bearer" },
writeTools: Object.freeze([
"create_record",
"update_record",
@@ -141,7 +183,7 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([
hostPattern:
"^https://([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)\\.service-now\\.com$",
path: "/sncapps/mcp-server",
- needsCredential: true,
+ auth: { kind: "deployment-bearer" },
writeTools: Object.freeze([
"create_record",
"update_record",
@@ -150,6 +192,44 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([
docsUrl:
"https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/mcp/concept/mcp-server.html",
},
+ {
+ key: "google-drive",
+ title: "Google Drive",
+ vendor: "Google",
+ summary: "Files in the Drive of whoever is asking.",
+ /*
+ * Google publishes one MCP server per Workspace product, each on its own host: Gmail, Docs,
+ * Sheets, Slides, Calendar, Chat and People have their own. Drive is here because it is the one
+ * a question about a document needs. Each of the others is a further entry, not a flag on this
+ * one, so adding Gmail stays a reviewed decision about Gmail.
+ */
+ host: "https://drivemcp.googleapis.com",
+ path: "/mcp/v1",
+ /*
+ * The first vendor here that cannot be reached with a token an administrator pastes. Google
+ * issues no such token: access is an authorization-code grant belonging to a person. That is
+ * not a limitation to work around, it is the property this connector exists for — two people
+ * asking the same question should get the answers their own accounts can see.
+ */
+ auth: {
+ kind: "user-oauth",
+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
+ tokenUrl: "https://oauth2.googleapis.com/token",
+ revokeUrl: "https://oauth2.googleapis.com/revoke",
+ // Read-only, because nothing in this slice writes to anybody's Drive.
+ scopes: Object.freeze(["https://www.googleapis.com/auth/drive.readonly"]),
+ },
+ /*
+ * Named writes even though the scope above makes Google refuse them.
+ *
+ * Belt and braces on purpose. The scope is what stops them; this list is what keeps a boundary
+ * written about writes covering them, so widening the scope later cannot quietly turn a write
+ * into something the policy engine has never heard of.
+ */
+ writeTools: Object.freeze(["create_file", "copy_file"]),
+ docsUrl:
+ "https://developers.google.com/workspace/guides/configure-mcp-servers",
+ },
]);
const BY_KEY = new Map(CATALOGUE.map((entry) => [entry.key, entry]));
diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts
index 59c6c2a3..69180174 100644
--- a/server/src/plugins/routes.ts
+++ b/server/src/plugins/routes.ts
@@ -70,7 +70,13 @@ export function createPluginRoutes(
vendor: entry.vendor,
summary: entry.summary,
docsUrl: entry.docsUrl,
- needsCredential: entry.needsCredential,
+ /*
+ * The kind, not the whole thing. The page needs to know what to ask an administrator for;
+ * it has no use for the vendor's OAuth addresses, and a URL this deployment sends an
+ * authorization code to is not improved by also existing in every browser that opens the
+ * Plugins page.
+ */
+ auth: entry.auth.kind,
perInstance: entry.host === null,
})),
servers: await store.listServers(),
diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts
index 7efca89e..0832a16c 100644
--- a/server/tests/plugin-catalogue.test.ts
+++ b/server/tests/plugin-catalogue.test.ts
@@ -90,6 +90,77 @@ describe("which servers this deployment will talk to", () => {
});
});
+describe("whose credential a server uses", () => {
+ test("every entry says which, rather than leaving it to be inferred", () => {
+ // The whole point of replacing a `needsCredential` boolean. "Needs a credential" did not say
+ // whose, and a reader who guessed would guess the deployment's, which for a user-oauth vendor
+ // is the one answer that breaks the promise the connector exists to keep.
+ for (const entry of CATALOGUE) {
+ expect(["none", "deployment-bearer", "user-oauth"]).toContain(
+ entry.auth.kind,
+ );
+ }
+ });
+
+ test("a user-oauth entry pins its own endpoints over https and asks for a scope", () => {
+ for (const entry of CATALOGUE) {
+ if (entry.auth.kind !== "user-oauth") continue;
+ // Pinned for the same reason the MCP host is: these are addresses this deployment sends a
+ // person's authorization code and receives their refresh token at.
+ expect(entry.auth.authorizationUrl.startsWith("https://")).toBe(true);
+ expect(entry.auth.tokenUrl.startsWith("https://")).toBe(true);
+ expect(entry.auth.revokeUrl.startsWith("https://")).toBe(true);
+ // No scopes means consent to nothing, which would fail at the vendor with a message that
+ // does not name us.
+ expect(entry.auth.scopes.length).toBeGreaterThan(0);
+ }
+ });
+
+ test("the five token vendors did not quietly become user-oauth", () => {
+ for (const key of [
+ "atlassian",
+ "box",
+ "slack",
+ "salesforce",
+ "servicenow",
+ ]) {
+ expect(catalogueEntry(key)?.auth.kind).toBe("deployment-bearer");
+ }
+ });
+});
+
+describe("Google Drive", () => {
+ const drive = catalogueEntry("google-drive");
+
+ test("resolves to the one address Google publishes for it", () => {
+ expect(drive).not.toBeNull();
+ expect(resolveServerUrl("google-drive")?.url).toBe(
+ "https://drivemcp.googleapis.com/mcp/v1",
+ );
+ });
+
+ test("is reached as the person asking, not as the deployment", () => {
+ expect(drive?.auth.kind).toBe("user-oauth");
+ });
+
+ test("asks only to read", () => {
+ // K1 answers questions and writes nothing. A wider scope would be granted by every person who
+ // connects and used by nothing, which is the kind of permission nobody remembers agreeing to.
+ expect(drive?.auth.kind === "user-oauth" ? drive.auth.scopes : []).toEqual([
+ "https://www.googleapis.com/auth/drive.readonly",
+ ]);
+ });
+
+ test("still calls its writes writes, and lets Google be the one to refuse them", () => {
+ // The read-only scope means these fail at the vendor. They stay classified as writes anyway, so
+ // a boundary written about writes keeps covering them if the scope ever widens.
+ expect(classifyTool(drive, "create_file", true)).toBe("write");
+ expect(classifyTool(drive, "copy_file", true)).toBe("write");
+ expect(classifyTool(drive, "search_files", true)).toBe("read");
+ expect(classifyTool(drive, "read_file_content", true)).toBe("read");
+ });
+});
+
describe("what a tool does", () => {
const atlassian = catalogueEntry("atlassian")!;
From 9e401495b575633d6c5cf20f91ca3649d067d032 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Thu, 20 Aug 2026 15:28:37 -0300
Subject: [PATCH 03/34] Say when a tool returned nothing, instead of returning
nothing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A tool that matched nothing produced an empty string, and an empty string is the
worst thing to put in front of a model. It reads as "the tool had nothing to say"
rather than "there is nothing there", and the model closes the gap from memory.
For a connector whose whole job is answering from a live system, that is the
failure mode: an answer with nothing behind it, delivered with the same confidence
as one with a document under it.
An empty result now says so in words, and says the part that matters — there is
nothing here to answer from. Every vendor, not just Drive, because the hazard is
in the shape of the answer rather than in who sent it.
The shaping moved out of `callTool` into `resultText`, which is a decision rather
than plumbing: it settles what a model is told when a vendor answers with nothing,
with something enormous, or with a part we cannot render. Out on its own it can be
asserted without a server to talk to, which is why it now has tests at all — this
module had none, because everything in it needed a live vendor.
Nothing is decided by trimming except whether the result is empty. A vendor that
sent one newline has said nothing, and which shape of nothing arrived should not
change what the model is told; but a blank part beside a real one is still a result
and is passed through untouched.
Behaviour otherwise unchanged, including the visible truncation and naming an
unreadable part rather than dropping it. The exact-limit boundary now has a test,
which the rewritten comparison would otherwise have been free to get wrong.
---
server/src/plugins/mcp.ts | 75 ++++++++++++++++++--------
server/tests/mcp-result.test.ts | 95 +++++++++++++++++++++++++++++++++
2 files changed, 148 insertions(+), 22 deletions(-)
create mode 100644 server/tests/mcp-result.test.ts
diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts
index 4ebc7844..4b698978 100644
--- a/server/src/plugins/mcp.ts
+++ b/server/src/plugins/mcp.ts
@@ -26,7 +26,57 @@ const CALL_TIMEOUT_MS = 60_000;
* deciding how much of our context window to spend, and a truncation the model can see is far better
* than a run that fails or a bill nobody expected. Truncated visibly, never silently.
*/
-const MAX_RESULT_CHARS = 20_000;
+export const MAX_RESULT_CHARS = 20_000;
+
+/**
+ * What a vendor said, as the string a model will read.
+ *
+ * Its own function, and exported, because this is a decision rather than plumbing: it settles what a
+ * model is told when a vendor answers with nothing, with something enormous, or with a part we
+ * cannot render. Keeping it out of {@link callTool} means it can be asserted without a server to
+ * talk to.
+ *
+ * The empty case is the one that earns the separation. A tool that matched nothing used to produce
+ * an empty string, and an empty string is the worst thing to put in front of a model: it reads as
+ * "the tool had nothing to say" rather than "there is nothing there", and the model closes the gap
+ * from memory. For a knowledge connector that is precisely the failure the whole slice exists to
+ * prevent — an answer with nothing behind it. So nothing is stated, in words.
+ */
+export function resultText(content: unknown): {
+ text: string;
+ truncated: boolean;
+} {
+ const parts = Array.isArray(content) ? content : [];
+ const joined = parts
+ .map((part) => {
+ const item = part as { type?: string; text?: string };
+ if (item.type === "text" && typeof item.text === "string") {
+ return item.text;
+ }
+ // A non-text part is named rather than dropped. A model told "[image]" can say the tool
+ // returned an image; a model handed nothing concludes the tool returned nothing.
+ return `[${item.type ?? "unknown"}]`;
+ })
+ .join("\n");
+
+ // Trimmed only to decide emptiness, never to alter a result that has something in it. A vendor
+ // that sent one newline has said nothing, and which shape of nothing arrived should not change
+ // what the model is told.
+ if (joined.trim() === "") {
+ return {
+ text: "The tool returned no content. Nothing was found, so there is nothing here to answer from.",
+ truncated: false,
+ };
+ }
+
+ if (joined.length <= MAX_RESULT_CHARS) {
+ return { text: joined, truncated: false };
+ }
+ return {
+ text: `${joined.slice(0, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`,
+ truncated: true,
+ };
+}
export type McpTool = {
name: string;
@@ -123,26 +173,7 @@ export async function callTool(
{ timeout: CALL_TIMEOUT_MS },
);
- const parts = Array.isArray(result.content) ? result.content : [];
- const text = parts
- .map((part) => {
- const item = part as { type?: string; text?: string };
- if (item.type === "text" && typeof item.text === "string") {
- return item.text;
- }
- // A non-text part is named rather than dropped. A model told "[image]" can say the tool
- // returned an image; a model handed nothing concludes the tool returned nothing.
- return `[${item.type ?? "unknown"}]`;
- })
- .join("\n");
-
- const truncated = text.length > MAX_RESULT_CHARS;
- return {
- text: truncated
- ? `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${text.length} characters]`
- : text,
- isError: result.isError === true,
- truncated,
- };
+ const { text, truncated } = resultText(result.content);
+ return { text, isError: result.isError === true, truncated };
});
}
diff --git a/server/tests/mcp-result.test.ts b/server/tests/mcp-result.test.ts
new file mode 100644
index 00000000..8388c8bb
--- /dev/null
+++ b/server/tests/mcp-result.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, test } from "bun:test";
+import { MAX_RESULT_CHARS, resultText } from "../src/plugins/mcp";
+
+/**
+ * What a vendor's answer looks like by the time a model reads it.
+ *
+ * Separated from the protocol so it can be asserted without a server to talk to. The case worth
+ * having tests for is the empty one: a tool that matched nothing used to hand back an empty string,
+ * and an empty string is the single most dangerous thing to put in front of a model. It reads as
+ * "the tool had nothing to say" rather than "there is nothing there", and the model fills the gap
+ * from memory — which is exactly the answer with nothing behind it that a knowledge connector must
+ * never give.
+ */
+
+describe("a result with nothing in it", () => {
+ test("says so, rather than being an empty string", () => {
+ const { text } = resultText([]);
+ expect(text).not.toBe("");
+ expect(text.toLowerCase()).toContain("no content");
+ // The clause that matters: it tells the model there is nothing here to answer from.
+ expect(text.toLowerCase()).toContain("nothing");
+ });
+
+ test("treats whitespace and a missing content field the same as empty", () => {
+ // A vendor sending a single newline has said nothing, and "nothing" should not depend on which
+ // shape of nothing arrived.
+ const blank = resultText([{ type: "text", text: " \n " }]).text;
+ expect(blank).toBe(resultText([]).text);
+ expect(resultText(undefined).text).toBe(resultText([]).text);
+ expect(resultText("not an array").text).toBe(resultText([]).text);
+ });
+
+ test("is not reported as truncated", () => {
+ expect(resultText([]).truncated).toBe(false);
+ });
+});
+
+describe("a result with something in it", () => {
+ test("is passed through as the vendor wrote it", () => {
+ const { text, truncated } = resultText([
+ {
+ type: "text",
+ text: "# Expense policy\n\nMeals under $75 need no receipt.",
+ },
+ ]);
+ expect(text).toBe("# Expense policy\n\nMeals under $75 need no receipt.");
+ expect(truncated).toBe(false);
+ });
+
+ test("joins several parts", () => {
+ expect(
+ resultText([
+ { type: "text", text: "first" },
+ { type: "text", text: "second" },
+ ]).text,
+ ).toBe("first\nsecond");
+ });
+
+ test("names a part it cannot read rather than dropping it", () => {
+ // A model told "[image]" can say the tool returned an image. A model handed nothing concludes
+ // the tool returned nothing, which is a different and false statement.
+ expect(resultText([{ type: "image", data: "..." }]).text).toBe("[image]");
+ expect(resultText([{}]).text).toBe("[unknown]");
+ });
+
+ test("a part that is only whitespace still counts as something being there", () => {
+ // One blank part beside a real one must not make the whole result look empty.
+ expect(
+ resultText([
+ { type: "text", text: " " },
+ { type: "text", text: "real" },
+ ]).text,
+ ).toContain("real");
+ });
+});
+
+describe("a result too large to hand a model", () => {
+ test("is cut, and says that it was", () => {
+ const enormous = "x".repeat(MAX_RESULT_CHARS + 500);
+ const { text, truncated } = resultText([{ type: "text", text: enormous }]);
+ expect(truncated).toBe(true);
+ expect(text.length).toBeLessThan(enormous.length);
+ // Visibly, never silently: a model that cannot tell it was given a fragment answers from the
+ // fragment as though it were the whole thing.
+ expect(text).toContain("truncated");
+ expect(text).toContain(String(enormous.length));
+ });
+
+ test("a result exactly at the limit is left alone", () => {
+ const exact = "x".repeat(MAX_RESULT_CHARS);
+ const { text, truncated } = resultText([{ type: "text", text: exact }]);
+ expect(truncated).toBe(false);
+ expect(text).toBe(exact);
+ });
+});
From 0ee45d4736fceb6daae42eaa11e4e8934a9a8ed3 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Thu, 20 Aug 2026 15:32:01 -0300
Subject: [PATCH 04/34] Give one person one credential per server
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The table that makes a Bot answer as the asker rather than as the deployment.
`mcp_servers.credential_id` holds what the DEPLOYMENT has. For a `user-oauth`
vendor that is an OAuth client, which identifies us to Google and reaches nobody's
documents by itself. What reaches somebody's documents is `mcp_user_credentials`,
one row per person per server, and a call picks the row belonging to whoever asked.
The key is the pair, not a surrogate id. "Which credential serves this server for
this person" has to have exactly one answer: with an id and no unique constraint
two rows for one pair are legal, and then the answer is whichever the query
happened to order first — so somebody who reconnected could keep being served the
grant they thought they had replaced.
`credential_id` is a real foreign key, unlike `mcp_servers.credential_id`, which is
`text` against a `uuid` primary key and so references nothing the database checks.
The new table does not copy that. It also does not cascade: a revoked credential is
kept for the trail, and deleting the row that says whose it was would take the
trail with it. The person and the server both do cascade — a deleted user must not
leave a row pointing at a secret held on their behalf, and a removed server must
not leave rows nobody can reach to disconnect.
`scope` stores what the vendor granted, not what we asked for. The two differ when
somebody declines part of a consent screen, and a tool failing for want of a scope
should be explainable rather than a mystery about a permission we assumed.
Two new credential kinds, because three different things deserve three names. `mcp`
is one token an administrator holds for everybody. `mcp_oauth_client` belongs to
the deployment and is what you rotate when it leaks. `mcp_user_token` belongs to one
person and reaches everything they can see. Filing all three under `mcp` would make
"what does this deployment hold" unanswerable without reading every row's metadata,
which is the question the vault exists to answer.
Two things fell out of that. `CredentialKind` was a hand-written union duplicating
the enum, with nothing keeping them in agreement; it is now derived from the enum.
That widens the type, so the API's credential allowlist — `model`, `connector`,
`mcp` — is now deliberately narrower than it, and says so: a user token exists only
as the outcome of a consent somebody gave, and an administrator hand-posting one
would be creating a credential attributed to a person who never agreed to it.
Migration adds the enum values and the table. Nothing writes to it yet.
---
server/drizzle/0002_numerous_leech.sql | 16 +
server/drizzle/meta/0002_snapshot.json | 2622 ++++++++++++++++++++++++
server/drizzle/meta/_journal.json | 7 +
server/src/app.ts | 11 +
server/src/credentials.ts | 11 +-
server/src/db/schema/core.ts | 17 +
server/src/db/schema/plugins.ts | 70 +-
server/tests/schema.test.ts | 68 +
8 files changed, 2819 insertions(+), 3 deletions(-)
create mode 100644 server/drizzle/0002_numerous_leech.sql
create mode 100644 server/drizzle/meta/0002_snapshot.json
diff --git a/server/drizzle/0002_numerous_leech.sql b/server/drizzle/0002_numerous_leech.sql
new file mode 100644
index 00000000..38c18f69
--- /dev/null
+++ b/server/drizzle/0002_numerous_leech.sql
@@ -0,0 +1,16 @@
+ALTER TYPE "public"."credential_kind" ADD VALUE 'mcp_oauth_client';--> statement-breakpoint
+ALTER TYPE "public"."credential_kind" ADD VALUE 'mcp_user_token';--> statement-breakpoint
+CREATE TABLE "mcp_user_credentials" (
+ "server_id" text NOT NULL,
+ "user_id" text NOT NULL,
+ "credential_id" uuid NOT NULL,
+ "scope" text NOT NULL,
+ "connected_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "mcp_user_credentials_server_id_user_id_pk" PRIMARY KEY("server_id","user_id")
+);
+--> statement-breakpoint
+ALTER TABLE "mcp_user_credentials" ADD CONSTRAINT "mcp_user_credentials_server_id_mcp_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."mcp_servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "mcp_user_credentials" ADD CONSTRAINT "mcp_user_credentials_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "mcp_user_credentials" ADD CONSTRAINT "mcp_user_credentials_credential_id_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."credentials"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "mcp_user_credentials_user_idx" ON "mcp_user_credentials" USING btree ("user_id");
\ No newline at end of file
diff --git a/server/drizzle/meta/0002_snapshot.json b/server/drizzle/meta/0002_snapshot.json
new file mode 100644
index 00000000..45fcee89
--- /dev/null
+++ b/server/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,2622 @@
+{
+ "id": "3174db30-7000-4924-820e-0a3282345aaa",
+ "prevId": "3fd9e9ec-f351-4dbc-ad12-7d887be8d0ce",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "accounts_provider_account_idx": {
+ "name": "accounts_provider_account_idx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agents": {
+ "name": "agents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "agent_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "configuration": {
+ "name": "configuration",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agents_package_id_deployment_packages_id_fk": {
+ "name": "agents_package_id_deployment_packages_id_fk",
+ "tableFrom": "agents",
+ "tableTo": "deployment_packages",
+ "columnsFrom": ["package_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_events": {
+ "name": "audit_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_events_created_at_idx": {
+ "name": "audit_events_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_agents": {
+ "name": "channel_agents",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_agents_channel_id_channels_id_fk": {
+ "name": "channel_agents_channel_id_channels_id_fk",
+ "tableFrom": "channel_agents",
+ "tableTo": "channels",
+ "columnsFrom": ["channel_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "channel_agents_agent_id_agents_id_fk": {
+ "name": "channel_agents_agent_id_agents_id_fk",
+ "tableFrom": "channel_agents",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_agents_channel_id_agent_id_pk": {
+ "name": "channel_agents_channel_id_agent_id_pk",
+ "columns": ["channel_id", "agent_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_memberships": {
+ "name": "channel_memberships",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_memberships_channel_id_channels_id_fk": {
+ "name": "channel_memberships_channel_id_channels_id_fk",
+ "tableFrom": "channel_memberships",
+ "tableTo": "channels",
+ "columnsFrom": ["channel_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "channel_memberships_user_id_users_id_fk": {
+ "name": "channel_memberships_user_id_users_id_fk",
+ "tableFrom": "channel_memberships",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_memberships_channel_id_user_id_pk": {
+ "name": "channel_memberships_channel_id_user_id_pk",
+ "columns": ["channel_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channels": {
+ "name": "channels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "suggested_prompts": {
+ "name": "suggested_prompts",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "allowed_groups": {
+ "name": "allowed_groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message": {
+ "name": "last_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_agent_id": {
+ "name": "last_message_agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "channels_recent_activity_idx": {
+ "name": "channels_recent_activity_idx",
+ "columns": [
+ {
+ "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "channels_package_id_deployment_packages_id_fk": {
+ "name": "channels_package_id_deployment_packages_id_fk",
+ "tableFrom": "channels",
+ "tableTo": "deployment_packages",
+ "columnsFrom": ["package_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "channels_last_message_agent_id_agents_id_fk": {
+ "name": "channels_last_message_agent_id_agents_id_fk",
+ "tableFrom": "channels",
+ "tableTo": "agents",
+ "columnsFrom": ["last_message_agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chunks": {
+ "name": "chunks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(1536)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "chunks_document_position_idx": {
+ "name": "chunks_document_position_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chunks_document_idx": {
+ "name": "chunks_document_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chunks_document_id_documents_id_fk": {
+ "name": "chunks_document_id_documents_id_fk",
+ "tableFrom": "chunks",
+ "tableTo": "documents",
+ "columnsFrom": ["document_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connector_cursors": {
+ "name": "connector_cursors",
+ "schema": "",
+ "columns": {
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "cursor": {
+ "name": "cursor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "connector_cursors_connector_instance_id_connector_instances_id_fk": {
+ "name": "connector_cursors_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "connector_cursors",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connector_instances": {
+ "name": "connector_instances",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "connector_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "source_metadata": {
+ "name": "source_metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "connector_instances_credential_id_credentials_id_fk": {
+ "name": "connector_instances_credential_id_credentials_id_fk",
+ "tableFrom": "connector_instances",
+ "tableTo": "credentials",
+ "columnsFrom": ["credential_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "credential_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_id": {
+ "name": "key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.deployment_packages": {
+ "name": "deployment_packages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_path": {
+ "name": "source_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "loaded_at": {
+ "name": "loaded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "deployment_packages_tenant_id_unique": {
+ "name": "deployment_packages_tenant_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["tenant_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document_acls": {
+ "name": "document_acls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "principal": {
+ "name": "principal",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "effect": {
+ "name": "effect",
+ "type": "acl_effect",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_acls_document_principal_effect_idx": {
+ "name": "document_acls_document_principal_effect_idx",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "principal",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "effect",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_acls_principal_idx": {
+ "name": "document_acls_principal_idx",
+ "columns": [
+ {
+ "expression": "principal",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_acls_document_id_documents_id_fk": {
+ "name": "document_acls_document_id_documents_id_fk",
+ "tableFrom": "document_acls",
+ "tableTo": "documents",
+ "columnsFrom": ["document_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.documents": {
+ "name": "documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_url": {
+ "name": "canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "documents_connector_source_idx": {
+ "name": "documents_connector_source_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "documents_connector_deleted_idx": {
+ "name": "documents_connector_deleted_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "documents_connector_instance_id_connector_instances_id_fk": {
+ "name": "documents_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.intelligence_channel_mappings": {
+ "name": "intelligence_channel_mappings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "intelligence_channel_mappings_thread_idx": {
+ "name": "intelligence_channel_mappings_thread_idx",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "intelligence_channel_mappings_user_id_users_id_fk": {
+ "name": "intelligence_channel_mappings_user_id_users_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "intelligence_channel_mappings_channel_id_channels_id_fk": {
+ "name": "intelligence_channel_mappings_channel_id_channels_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "tableTo": "channels",
+ "columnsFrom": ["channel_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "intelligence_channel_mappings_user_id_channel_id_pk": {
+ "name": "intelligence_channel_mappings_user_id_channel_id_pk",
+ "columns": ["user_id", "channel_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sync_runs": {
+ "name": "sync_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stats": {
+ "name": "stats",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sync_runs_connector_started_at_idx": {
+ "name": "sync_runs_connector_started_at_idx",
+ "columns": [
+ {
+ "expression": "connector_instance_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sync_runs_connector_instance_id_connector_instances_id_fk": {
+ "name": "sync_runs_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "sync_runs",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_roles": {
+ "name": "user_roles",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_roles_user_id_users_id_fk": {
+ "name": "user_roles_user_id_users_id_fk",
+ "tableFrom": "user_roles",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_roles_user_id_role_pk": {
+ "name": "user_roles_user_id_role_pk",
+ "columns": ["user_id", "role"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verifications": {
+ "name": "verifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhook_subscriptions": {
+ "name": "webhook_subscriptions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "connector_instance_id": {
+ "name": "connector_instance_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_subscription_id": {
+ "name": "provider_subscription_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "webhook_subscriptions_connector_instance_id_connector_instances_id_fk": {
+ "name": "webhook_subscriptions_connector_instance_id_connector_instances_id_fk",
+ "tableFrom": "webhook_subscriptions",
+ "tableTo": "connector_instances",
+ "columnsFrom": ["connector_instance_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_policy": {
+ "name": "action_policy",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deny": {
+ "name": "deny",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allow": {
+ "name": "allow",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_preferences": {
+ "name": "agent_preferences",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hidden_at": {
+ "name": "hidden_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agent_preferences_user_id_users_id_fk": {
+ "name": "agent_preferences_user_id_users_id_fk",
+ "tableFrom": "agent_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_preferences_agent_id_agents_id_fk": {
+ "name": "agent_preferences_agent_id_agents_id_fk",
+ "tableFrom": "agent_preferences",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "agent_preferences_user_id_agent_id_pk": {
+ "name": "agent_preferences_user_id_agent_id_pk",
+ "columns": ["user_id", "agent_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_profiles": {
+ "name": "agent_profiles",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_description": {
+ "name": "role_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar_seed": {
+ "name": "avatar_seed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "agent_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "callback_token_hash": {
+ "name": "callback_token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "callback_token_issued_at": {
+ "name": "callback_token_issued_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_profiles_visibility_deleted_idx": {
+ "name": "agent_profiles_visibility_deleted_idx",
+ "columns": [
+ {
+ "expression": "visibility",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_profiles_agent_id_agents_id_fk": {
+ "name": "agent_profiles_agent_id_agents_id_fk",
+ "tableFrom": "agent_profiles",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_profiles_owner_user_id_users_id_fk": {
+ "name": "agent_profiles_owner_user_id_users_id_fk",
+ "tableFrom": "agent_profiles",
+ "tableTo": "users",
+ "columnsFrom": ["owner_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_exclusions": {
+ "name": "component_exclusions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "withheld_by": {
+ "name": "withheld_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_exclusions_component_name_components_name_fk": {
+ "name": "component_exclusions_component_name_components_name_fk",
+ "tableFrom": "component_exclusions",
+ "tableTo": "components",
+ "columnsFrom": ["component_name"],
+ "columnsTo": ["name"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "component_exclusions_agent_id_agents_id_fk": {
+ "name": "component_exclusions_agent_id_agents_id_fk",
+ "tableFrom": "component_exclusions",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_exclusions_component_name_agent_id_pk": {
+ "name": "component_exclusions_component_name_agent_id_pk",
+ "columns": ["component_name", "agent_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_functions": {
+ "name": "component_functions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "function_name": {
+ "name": "function_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_functions_component_name_components_name_fk": {
+ "name": "component_functions_component_name_components_name_fk",
+ "tableFrom": "component_functions",
+ "tableTo": "components",
+ "columnsFrom": ["component_name"],
+ "columnsTo": ["name"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_functions_component_name_function_name_pk": {
+ "name": "component_functions_component_name_function_name_pk",
+ "columns": ["component_name", "function_name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.components": {
+ "name": "components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_servers": {
+ "name": "mcp_servers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provenance": {
+ "name": "provenance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'first-party'"
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tools_refreshed_at": {
+ "name": "tools_refreshed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_tools": {
+ "name": "mcp_tools",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mcp_tools_server_id_mcp_servers_id_fk": {
+ "name": "mcp_tools_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_tools",
+ "tableTo": "mcp_servers",
+ "columnsFrom": ["server_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_tools_server_id_name_pk": {
+ "name": "mcp_tools_server_id_name_pk",
+ "columns": ["server_id", "name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_user_credentials": {
+ "name": "mcp_user_credentials",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "mcp_user_credentials_user_idx": {
+ "name": "mcp_user_credentials_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "mcp_user_credentials_server_id_mcp_servers_id_fk": {
+ "name": "mcp_user_credentials_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "mcp_servers",
+ "columnsFrom": ["server_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_user_credentials_user_id_users_id_fk": {
+ "name": "mcp_user_credentials_user_id_users_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_user_credentials_credential_id_credentials_id_fk": {
+ "name": "mcp_user_credentials_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "credentials",
+ "columnsFrom": ["credential_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_user_credentials_server_id_user_id_pk": {
+ "name": "mcp_user_credentials_server_id_user_id_pk",
+ "columns": ["server_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_grants": {
+ "name": "plugin_grants",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_grants_agent_idx": {
+ "name": "plugin_grants_agent_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_grants_agent_id_agents_id_fk": {
+ "name": "plugin_grants_agent_id_agents_id_fk",
+ "tableFrom": "plugin_grants",
+ "tableTo": "agents",
+ "columnsFrom": ["agent_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "plugin_grants_kind_ref_agent_id_pk": {
+ "name": "plugin_grants_kind_ref_agent_id_pk",
+ "columns": ["kind", "ref", "agent_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sandboxed_components": {
+ "name": "sandboxed_components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_html": {
+ "name": "draft_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_css": {
+ "name": "draft_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_js_functions": {
+ "name": "draft_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_argument_schema": {
+ "name": "draft_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_html": {
+ "name": "published_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_css": {
+ "name": "published_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_js_functions": {
+ "name": "published_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_argument_schema": {
+ "name": "published_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sample_arguments": {
+ "name": "sample_arguments",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authored_by": {
+ "name": "authored_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.skills": {
+ "name": "skills",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instructions": {
+ "name": "instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'yours'"
+ },
+ "installed_by": {
+ "name": "installed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skills_slug_key": {
+ "name": "skills_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "skills_owner_idx": {
+ "name": "skills_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "skills_owner_user_id_users_id_fk": {
+ "name": "skills_owner_user_id_users_id_fk",
+ "tableFrom": "skills",
+ "tableTo": "users",
+ "columnsFrom": ["owner_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.acl_effect": {
+ "name": "acl_effect",
+ "schema": "public",
+ "values": ["allow", "deny"]
+ },
+ "public.agent_type": {
+ "name": "agent_type",
+ "schema": "public",
+ "values": ["built_in", "remote_ag_ui"]
+ },
+ "public.connector_type": {
+ "name": "connector_type",
+ "schema": "public",
+ "values": ["google_drive", "onedrive"]
+ },
+ "public.credential_kind": {
+ "name": "credential_kind",
+ "schema": "public",
+ "values": [
+ "model",
+ "connector",
+ "agent",
+ "mcp",
+ "mcp_oauth_client",
+ "mcp_user_token"
+ ]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": ["admin", "user"]
+ },
+ "public.sync_status": {
+ "name": "sync_status",
+ "schema": "public",
+ "values": ["pending", "running", "succeeded", "failed"]
+ },
+ "public.agent_visibility": {
+ "name": "agent_visibility",
+ "schema": "public",
+ "values": ["public", "private"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json
index 64c4fbfb..5aa094f9 100644
--- a/server/drizzle/meta/_journal.json
+++ b/server/drizzle/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1787198911059,
"tag": "0001_swift_morph",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1787250609174,
+ "tag": "0002_numerous_leech",
+ "breakpoints": true
}
]
}
diff --git a/server/src/app.ts b/server/src/app.ts
index e97946b1..2423be6b 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -400,6 +400,17 @@ function credentialInput(
return null;
}
const body = value as Record;
+ /*
+ * An allowlist, and deliberately narrower than `CredentialKind`.
+ *
+ * `CredentialKind` is derived from the schema enum, so it now includes `mcp_oauth_client` and
+ * `mcp_user_token`. Neither belongs here. A user token is somebody's own grant and exists only as
+ * the outcome of a consent they gave; a client is registered when a connector is added. Both are
+ * written by the code that owns those flows, and an administrator hand-posting either would be
+ * creating a credential attributed to a person who never agreed to it.
+ *
+ * So this list is not out of date with the enum — do not widen it to match.
+ */
if (
(body.kind !== "model" &&
body.kind !== "connector" &&
diff --git a/server/src/credentials.ts b/server/src/credentials.ts
index ddd6c599..af3cc901 100644
--- a/server/src/credentials.ts
+++ b/server/src/credentials.ts
@@ -1,7 +1,7 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { type AuditStore, recordAuditEvent } from "./audit";
import type { Database } from "./db/client";
-import { credentials } from "./db/schema";
+import { type credentialKind, credentials } from "./db/schema";
type CredentialEnvelope = {
version: 1;
@@ -12,7 +12,14 @@ type CredentialEnvelope = {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
-export type CredentialKind = "model" | "connector" | "agent" | "mcp";
+/**
+ * Derived from the enum rather than written out again.
+ *
+ * These were two lists that had to agree, and nothing made them. A kind added to the schema and not
+ * here fails at the point of use with a type error about an unrelated call site; a kind removed from
+ * the schema and left here compiles and then violates a check constraint at runtime. One source now.
+ */
+export type CredentialKind = (typeof credentialKind.enumValues)[number];
export type CredentialStatus = {
id: string;
diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts
index 445f6c42..82bee083 100644
--- a/server/src/db/schema/core.ts
+++ b/server/src/db/schema/core.ts
@@ -31,6 +31,23 @@ export const credentialKind = pgEnum("credential_kind", [
// A token for an MCP server. Same vault and same revocation as everything else, so the server row
// holds a pointer and never the secret.
"mcp",
+ /*
+ * A deployment's OAuth client for an MCP server: the id and the secret an administrator registered
+ * with the vendor.
+ *
+ * Its own kind rather than another `mcp`, because it is a different thing with different reach. A
+ * client identifies this deployment to a vendor and can read nobody's data on its own; it is the
+ * thing you must have before anybody can consent, and the thing you rotate when it leaks.
+ */
+ "mcp_oauth_client",
+ /*
+ * One person's refresh token for one MCP server.
+ *
+ * The far end of the same flow and the opposite risk: this reaches everything that person can see.
+ * Distinct from the client so that "what does this deployment hold" stays answerable — one row
+ * that speaks for the deployment, and one row per person that speaks for them.
+ */
+ "mcp_user_token",
]);
export const connectorType = pgEnum("connector_type", [
"google_drive",
diff --git a/server/src/db/schema/plugins.ts b/server/src/db/schema/plugins.ts
index 482d058c..736e87fe 100644
--- a/server/src/db/schema/plugins.ts
+++ b/server/src/db/schema/plugins.ts
@@ -7,8 +7,9 @@ import {
text,
timestamp,
uniqueIndex,
+ uuid,
} from "drizzle-orm/pg-core";
-import { agents, users } from "./core";
+import { agents, credentials, users } from "./core";
import { jsonb } from "./json";
const createdAt = () =>
@@ -96,6 +97,73 @@ export const mcpTools = pgTable(
(table) => [primaryKey({ columns: [table.serverId, table.name] })],
);
+/**
+ * One person's grant on one MCP server: the row that makes a Bot answer as the asker.
+ *
+ * A table rather than a column, and this is the whole architectural point of the knowledge lane.
+ * `mcp_servers.credential_id` holds what the DEPLOYMENT has — for a `user-oauth` vendor that is the
+ * OAuth client, which reaches nobody's documents by itself. What reaches somebody's documents is
+ * here, one row per person, and a call picks the row belonging to whoever asked. Two people asking
+ * the same question therefore get the answers their own accounts can see, and neither can be served
+ * the other's.
+ *
+ * The key is the pair. "Which credential serves this server for this person" must have exactly one
+ * answer: with a surrogate id and no unique constraint, two rows for one pair are legal, and then
+ * the answer is whichever the query happened to order first — so somebody who reconnected could keep
+ * being served the grant they thought they had replaced.
+ *
+ * A pointer to the vault, never the secret, the same as everywhere else. The vault owns encryption,
+ * rotation and revocation, and a second copy of a refresh token here would be a second thing to
+ * remember to revoke when somebody disconnects.
+ */
+export const mcpUserCredentials = pgTable(
+ "mcp_user_credentials",
+ {
+ serverId: text("server_id")
+ .notNull()
+ .references(() => mcpServers.id, { onDelete: "cascade" }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ /**
+ * The vault row holding this person's refresh token.
+ *
+ * A real foreign key, unlike {@link mcpServers.credentialId}, which is `text` against a `uuid`
+ * primary key and so references nothing the database will check. The new table does not copy
+ * that.
+ *
+ * Deliberately not cascading. A revoked credential row is kept for the trail, and deleting the
+ * row that says whose it was would take the trail with it.
+ */
+ credentialId: uuid("credential_id")
+ .notNull()
+ .references(() => credentials.id),
+ /**
+ * What the vendor actually granted, as it said it — not what we asked for.
+ *
+ * The two differ in practice: a person can decline part of a consent screen. Storing the reply
+ * rather than the request means a tool failing for want of a scope can be explained instead of
+ * being a mystery about a permission we assumed we had.
+ */
+ scope: text("scope").notNull(),
+ /**
+ * When this person connected.
+ *
+ * Written out rather than using the shared `createdAt()` helper, which fixes the column name to
+ * `created_at`. This row records an act somebody performed and a date they are shown on their
+ * own settings page, so it is worth the column saying which act.
+ */
+ connectedAt: timestamp("connected_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ updatedAt: updatedAt(),
+ },
+ (table) => [
+ primaryKey({ columns: [table.serverId, table.userId] }),
+ index("mcp_user_credentials_user_idx").on(table.userId),
+ ],
+);
+
/**
* A packaged skill: a named instruction a person invokes with `/` and a Bot follows.
*
diff --git a/server/tests/schema.test.ts b/server/tests/schema.test.ts
index 00fe5b25..32920296 100644
--- a/server/tests/schema.test.ts
+++ b/server/tests/schema.test.ts
@@ -16,9 +16,11 @@ import {
connectorCursors,
connectorInstances,
credentials,
+ credentialKind,
documentAcls,
documents,
intelligenceChannelMappings,
+ mcpUserCredentials,
sessions,
syncRuns,
userRoles,
@@ -71,6 +73,72 @@ describe("OpenBot database schema", () => {
]);
});
+ test("names the two kinds of OAuth secret separately from a shared token", () => {
+ /*
+ * Three different things, three names. `mcp` is one token an administrator holds for everybody.
+ * An OAuth client belongs to the deployment and reaches nobody's data by itself; a refresh token
+ * belongs to one person and reaches everything they can see. Filing all three under `mcp` would
+ * make "what does this deployment hold" unanswerable without reading the metadata of every row,
+ * and it is the question the vault exists to answer.
+ */
+ expect(credentialKind.enumValues).toEqual([
+ "model",
+ "connector",
+ "agent",
+ "mcp",
+ "mcp_oauth_client",
+ "mcp_user_token",
+ ]);
+ });
+
+ test("gives one person one credential per server, and makes that the key", () => {
+ expect(getTableName(mcpUserCredentials)).toBe("mcp_user_credentials");
+
+ const config = getTableConfig(mcpUserCredentials);
+
+ /*
+ * A composite primary key, not a surrogate id.
+ *
+ * "Which credential serves this server for this person" must have exactly one answer. With an id
+ * and no unique constraint, two rows for the same pair are legal, and then the answer depends on
+ * whichever the query happened to order first — so a person who reconnected could keep being
+ * served the grant they thought they had replaced.
+ */
+ expect(
+ config.primaryKeys.flatMap((key) =>
+ key.columns.map((column) => column.name),
+ ),
+ ).toEqual(["server_id", "user_id"]);
+
+ expect(
+ config.columns.map((column) => ({
+ name: column.name,
+ notNull: column.notNull,
+ })),
+ ).toEqual([
+ { name: "server_id", notNull: true },
+ { name: "user_id", notNull: true },
+ { name: "credential_id", notNull: true },
+ { name: "scope", notNull: true },
+ { name: "connected_at", notNull: true },
+ { name: "updated_at", notNull: true },
+ ]);
+ });
+
+ test("follows the person and the server when either goes away", () => {
+ const config = getTableConfig(mcpUserCredentials);
+ const cascading = config.foreignKeys.filter(
+ (key) => key.onDelete === "cascade",
+ );
+ /*
+ * Both the person and the server cascade: a deleted user must not leave a row pointing at a
+ * vault secret held on their behalf, and a removed server must not leave rows nobody can reach
+ * to disconnect. The credential reference deliberately does not cascade — a revoked credential
+ * is kept for the trail, and losing the row that says whose it was would take the trail with it.
+ */
+ expect(cascading.length).toBe(2);
+ });
+
test("keeps document embeddings and ACLs separate from document metadata", () => {
expect(Object.keys(documents)).toEqual(
expect.arrayContaining([
From 0f8e6337fdb25ae66ff22c6a020624165d77735a Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Thu, 20 Aug 2026 15:38:05 -0300
Subject: [PATCH 05/34] Call a user-oauth server as the person asking, or not
at all
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The selection that makes the whole knowledge lane mean anything: a call to a
`user-oauth` server goes out on the asker's own grant, and every branch that
cannot prove it has that grant refuses.
There is deliberately no fallback. A fallback is the one bug this design exists to
make impossible — answering out of whatever the deployment, or the last person to
connect, happened to be able to see. That failure is silent by construction: it
returns a confident answer assembled from documents the person asking cannot open,
and it looks exactly like a correct answer. So the refusals are what the new tests
are about, and each one asserts that nothing was decrypted rather than only that an
error was thrown.
Four ways it refuses, each with a sentence aimed at whoever can act on it:
- not connected: tells the person to connect in Settings
- actor unattributable: `identifyActor` answers `{ id: "" }` when it cannot say
who is asking, and an empty string must never match a row, so it is refused
before the query rather than trusted to miss
- credential revoked: tells them to connect again. The vault already refused a
revoked secret, but by throwing, which reaches a person as "that tool could not
be called" — indistinguishable from the vendor being down. A withdrawn grant is
not a fault
- no OAuth client registered: names the administrator's job, because the person
did their part and cannot fix this one
Nothing is cached. The refresh token is exchanged for an access token per call and
the access token is thrown away, so no stored copy of anybody's access exists for a
disconnect to have to find — revocation is complete by construction rather than by
cleanup. The cost is a round trip to the vendor's token endpoint on every call.
This also closes a hole that would have been ugly. `refreshTools` decrypted
`row.credentialId` and passed it as a bearer token. For a `user-oauth` server that
column holds the OAuth CLIENT, so listing tools would have sent the deployment's
client secret to the vendor as somebody's access token. Listing now goes through the
same selection as calling, so there is one answer to "what token does this server
get" and it cannot be a secret of the wrong kind. It takes the actor for that
reason: an administrator who has not connected gets a refusal recorded in
`lastError` and shown on the Plugins page, which is honest — until somebody
connects, this deployment does not know what that server offers.
The audit payload gains `reachedAs`. Two rows for the same tool and the same Bot can
legitimately have seen entirely different documents, and without it nothing in the
row says why.
`callVendor` and `exchangeRefreshToken` are injected, defaulting to the real ones.
Whose credential is chosen is this module's security property, and asserting it
otherwise needs a reachable vendor — which would leave the property most worth
testing as the one thing never tested.
Nothing writes `mcp_user_credentials` yet, so in practice every user-oauth call
refuses. The connect flow is next.
---
server/src/plugins/routes.ts | 5 +-
server/src/plugins/store.ts | 260 ++++++++++++-
...plugin-user-credential.integration.test.ts | 356 ++++++++++++++++++
3 files changed, 605 insertions(+), 16 deletions(-)
create mode 100644 server/tests/plugin-user-credential.integration.test.ts
diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts
index 69180174..c09b5efd 100644
--- a/server/src/plugins/routes.ts
+++ b/server/src/plugins/routes.ts
@@ -173,7 +173,10 @@ export function createPluginRoutes(
if (forbidden) return forbidden;
try {
- const result = await store.refreshTools(context.req.param("id"));
+ const result = await store.refreshTools(
+ context.req.param("id"),
+ context.var.actor.id,
+ );
const servers = await store.listServers();
return context.json({
tools: result.tools,
diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts
index b4caf395..6840d9bd 100644
--- a/server/src/plugins/store.ts
+++ b/server/src/plugins/store.ts
@@ -14,6 +14,7 @@ import {
agentProfiles,
mcpServers,
mcpTools,
+ mcpUserCredentials,
pluginGrants,
skills,
} from "../db/schema";
@@ -152,6 +153,70 @@ export function refFromToolName(toolName: string): string | null {
const iso = (value: Date | string | null): string | null =>
value === null ? null : value instanceof Date ? value.toISOString() : value;
+/**
+ * Trade a refresh token for a short-lived access token, at the vendor's own token endpoint.
+ *
+ * `tokenUrl` comes from the catalogue entry and never from a caller, for the same reason the MCP
+ * host does not: this request carries the deployment's client secret and somebody's refresh token,
+ * so where it goes is a reviewed decision rather than a runtime one.
+ *
+ * The vendor's error body is deliberately not passed through. It is written for whoever registered
+ * the client, not for the person who asked a Bot a question, and it can name the client id.
+ */
+async function exchangeRefreshTokenOverHttp(input: {
+ tokenUrl: string;
+ client: OAuthClient;
+ refreshToken: string;
+}): Promise {
+ const response = await fetch(input.tokenUrl, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: input.refreshToken,
+ client_id: input.client.clientId,
+ client_secret: input.client.clientSecret,
+ }),
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
+ });
+
+ if (!response.ok) {
+ throw new McpServerError(
+ `The vendor would not renew this access (${response.status}).`,
+ );
+ }
+
+ const body = (await response.json()) as {
+ access_token?: unknown;
+ expires_in?: unknown;
+ };
+ if (typeof body.access_token !== "string" || !body.access_token) {
+ throw new McpServerError("The vendor renewed this access with no token.");
+ }
+ return {
+ accessToken: body.access_token,
+ expiresInSeconds:
+ typeof body.expires_in === "number" ? body.expires_in : undefined,
+ };
+}
+
+/** How long a vendor's token endpoint gets. Shorter than a call: it is one round trip, or nothing. */
+const TOKEN_TIMEOUT_MS = 10_000;
+
+/**
+ * The deployment's OAuth client for one vendor, as it is held in the vault.
+ *
+ * Both halves live in the encrypted value rather than the id sitting in `metadata` and the secret
+ * here. One read gets a usable client, which keeps {@link CredentialSecretReader} the only vault
+ * interface this module needs. The id is also copied into `metadata` for the credentials page to
+ * show — a deliberate duplication of something that is not a secret, so that a screen listing what
+ * the deployment holds does not have to decrypt anything to name it.
+ */
+export type OAuthClient = { clientId: string; clientSecret: string };
+
+/** What a vendor's token endpoint gave back for a refresh token. */
+export type AccessToken = { accessToken: string; expiresInSeconds?: number };
+
export type PluginStoreOptions = {
database: Database;
auditStore: AuditStore;
@@ -159,10 +224,31 @@ export type PluginStoreOptions = {
encryptionKey: string;
/** Read at call time, never captured, so a policy changed a moment ago applies to this call. */
policy: () => ActionPolicy;
+ /**
+ * Speaking MCP to the vendor. Defaults to the real client.
+ *
+ * Injected so a test can assert what a call was about to go out with. Whose credential is chosen
+ * is the security property of this module, and asserting it otherwise needs a vendor to be
+ * reachable, which means the property most worth testing would be the one thing never tested.
+ */
+ callVendor?: (
+ connection: { url: string; token?: string },
+ toolName: string,
+ args: Record,
+ ) => Promise<{ text: string; isError: boolean }>;
+ /** Trading a refresh token for a short-lived access token. Defaults to a real HTTP exchange. */
+ exchangeRefreshToken?: (input: {
+ tokenUrl: string;
+ client: OAuthClient;
+ refreshToken: string;
+ }) => Promise;
};
export function createPluginStore(options: PluginStoreOptions) {
const { database, auditStore, credentials, encryptionKey } = options;
+ const callVendor = options.callVendor ?? callRemoteTool;
+ const exchangeRefreshToken =
+ options.exchangeRefreshToken ?? exchangeRefreshTokenOverHttp;
async function grantsFor(kind: PluginKind, refs: string[]) {
if (refs.length === 0) return new Map();
@@ -184,12 +270,123 @@ export function createPluginStore(options: PluginStoreOptions) {
* there does not fail loudly: the insert violates the constraint and the entire audit row is lost.
*/
- /** The credential for a server, decrypted for one call and never held. */
- async function tokenFor(
- credentialId: string | null,
- ): Promise {
- if (!credentialId) return undefined;
- return decryptCredentialForUse(encryptionKey, credentials, credentialId);
+ /**
+ * A credential out of the vault, decrypted for one call and never held.
+ *
+ * A revoked credential is turned into a refusal rather than left as the vault's thrown error. The
+ * two reach a person very differently: an error becomes "that tool could not be called", which is
+ * what a vendor being down looks like, while a withdrawn grant is nobody's fault and has an
+ * obvious next step. `reconnect` says which of the two to name.
+ */
+ async function secretFor(
+ credentialId: string,
+ onRevoked: string,
+ ): Promise {
+ try {
+ return await decryptCredentialForUse(
+ encryptionKey,
+ credentials,
+ credentialId,
+ );
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (message.includes("revoked") || message.includes("not found")) {
+ throw new PluginRefusedError(onRevoked, null);
+ }
+ throw error;
+ }
+ }
+
+ /**
+ * The token one call goes out with, and whose it is.
+ *
+ * For a `deployment-bearer` server this is what it always was: the one credential an administrator
+ * gave the server, used for everybody.
+ *
+ * For a `user-oauth` server it is the asker's own, and every branch that cannot prove it has the
+ * asker's grant refuses. There is deliberately no fallback. A fallback is the one bug this design
+ * exists to make impossible: answering out of whatever the deployment, or the last person to
+ * connect, happened to be able to see — which returns a confident answer assembled from documents
+ * the person asking cannot open, and looks exactly like a correct answer.
+ *
+ * Nothing is cached. The refresh token is exchanged for an access token per call and the access
+ * token is thrown away, so there is no stored copy of anybody's access for a disconnect to have to
+ * find. That costs a round trip to the vendor's token endpoint on every call, which is the price
+ * of revocation being complete by construction rather than by cleanup.
+ */
+ async function connectionTokenFor(
+ row: { id: string; url: string; credentialId: string | null },
+ entry: CatalogueEntry | null,
+ actorId: string,
+ ): Promise<{ token?: string; credentialOwner: string }> {
+ if (entry?.auth.kind !== "user-oauth") {
+ const token = row.credentialId
+ ? await secretFor(
+ row.credentialId,
+ `${row.id} needs a credential this deployment no longer holds. An administrator has to add it again.`,
+ )
+ : undefined;
+ return { token, credentialOwner: "deployment" };
+ }
+
+ /*
+ * The anonymous actor is the empty string, and an empty string must never match a row.
+ *
+ * `identifyActor` answers with `{ id: "" }` when it cannot resolve who is asking. Letting that
+ * reach the lookup would mean a run nobody can be held accountable for picking up whichever
+ * grant sorted first, so it is refused before the query rather than trusted to miss.
+ */
+ if (!actorId) {
+ throw new PluginRefusedError(
+ `${row.id} answers as the person asking, and this run is not attributed to anybody.`,
+ null,
+ );
+ }
+
+ const [held] = await database
+ .select({ credentialId: mcpUserCredentials.credentialId })
+ .from(mcpUserCredentials)
+ .where(
+ and(
+ eq(mcpUserCredentials.serverId, row.id),
+ eq(mcpUserCredentials.userId, actorId),
+ ),
+ )
+ .limit(1);
+
+ if (!held) {
+ throw new PluginRefusedError(
+ `You have not connected your ${entry.title} account. Connect it in Settings and ask again.`,
+ null,
+ );
+ }
+
+ const refreshToken = await secretFor(
+ held.credentialId,
+ `Your ${entry.title} access was withdrawn. Connect it again in Settings.`,
+ );
+
+ if (!row.credentialId) {
+ // The person did their part; the deployment has not. Said plainly, because the person cannot
+ // fix it and should not be told to try.
+ throw new PluginRefusedError(
+ `${entry.title} has no OAuth client registered for this deployment, so this cannot be called. An administrator has to add one.`,
+ null,
+ );
+ }
+ const client = JSON.parse(
+ await secretFor(
+ row.credentialId,
+ `${entry.title} has no usable OAuth client for this deployment. An administrator has to add one again.`,
+ ),
+ ) as OAuthClient;
+
+ const minted = await exchangeRefreshToken({
+ tokenUrl: entry.auth.tokenUrl,
+ client,
+ refreshToken,
+ });
+ return { token: minted.accessToken, credentialOwner: actorId };
}
async function requireServer(serverId: string) {
@@ -364,12 +561,32 @@ export function createPluginStore(options: PluginStoreOptions) {
*
* Replaced wholesale, never merged. A tool a vendor withdrew has to stop being offered, and a
* merge would leave it in the list forever as a name the model will happily call.
+ *
+ * `actorId` is who is asking, and it matters for a `user-oauth` server: there is no deployment
+ * credential to list with, so the listing runs on the grant of whoever pressed refresh. An
+ * administrator who has not connected their own account gets a refusal, which lands in
+ * `lastError` and is shown on the Plugins page — the honest state, because until somebody has
+ * connected, this deployment genuinely does not know what that server offers.
+ *
+ * Absent for the refresh that happens right after a server is added, where nobody can have
+ * connected yet. It makes no difference to a `deployment-bearer` server, which never consults it.
*/
- async refreshTools(serverId: string): Promise<{ tools: number }> {
- const { row } = await requireServer(serverId);
+ async refreshTools(
+ serverId: string,
+ actorId = "",
+ ): Promise<{ tools: number }> {
+ const { row, entry } = await requireServer(serverId);
try {
- const token = await tokenFor(row.credentialId);
+ /*
+ * Not `row.credentialId` decrypted directly, which is what this used to do.
+ *
+ * For a `user-oauth` server that column holds the OAuth CLIENT, and handing it over as a
+ * bearer token would have sent the deployment's client secret to the vendor as somebody's
+ * access token. Going through the same selection the call path uses means there is one
+ * answer to "what token does this server get", and it cannot be a secret of the wrong kind.
+ */
+ const { token } = await connectionTokenFor(row, entry, actorId);
const tools = await listTools({ url: row.url, token });
await database.delete(mcpTools).where(eq(mcpTools.serverId, serverId));
@@ -818,6 +1035,16 @@ export function createPluginStore(options: PluginStoreOptions) {
server: serverId,
tool: toolName,
effect,
+ /*
+ * Whose credential this call would go out with.
+ *
+ * `deployment` for a shared token; a user id for a server reached as the asker. Without it
+ * the trail cannot answer "who did this run reach as", which is the whole question a
+ * per-person connector raises — two rows for the same tool and the same Bot can legitimately
+ * have seen entirely different documents, and nothing else in the row says why.
+ */
+ reachedAs:
+ entry?.auth.kind === "user-oauth" ? input.actorId : "deployment",
decision: {
allowed: verdict.allowed,
mode: verdict.mode,
@@ -832,12 +1059,15 @@ export function createPluginStore(options: PluginStoreOptions) {
throw new PluginRefusedError(verdict.reason, verdict.matched);
}
- const token = await tokenFor(row.credentialId);
- const result = await callRemoteTool(
- { url: row.url, token },
- toolName,
- args,
- );
+ /*
+ * Whose credential, decided after the policy and before the network.
+ *
+ * A refusal here is still a refusal: it means this call was permitted in principle and cannot
+ * be made as this person, which is a different sentence from "not allowed" and a different one
+ * again from "it broke".
+ */
+ const { token } = await connectionTokenFor(row, entry, input.actorId);
+ const result = await callVendor({ url: row.url, token }, toolName, args);
return { text: result.text, isError: result.isError };
},
};
diff --git a/server/tests/plugin-user-credential.integration.test.ts b/server/tests/plugin-user-credential.integration.test.ts
new file mode 100644
index 00000000..5cc6ce45
--- /dev/null
+++ b/server/tests/plugin-user-credential.integration.test.ts
@@ -0,0 +1,356 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { randomUUID } from "node:crypto";
+import { eq } from "drizzle-orm";
+import { createAuditStore } from "../src/audit";
+import type { ActionPolicy } from "../src/computer/policy";
+import { encryptSecret } from "../src/credentials";
+import { createDatabase } from "../src/db/client";
+import {
+ agents,
+ credentials,
+ mcpServers,
+ mcpTools,
+ mcpUserCredentials,
+ pluginGrants,
+ users,
+} from "../src/db/schema";
+import { createPluginStore, PluginRefusedError } from "../src/plugins/store";
+import { TEST_POOL } from "./support/database";
+
+/**
+ * Whose credential a call to a `user-oauth` server goes out with.
+ *
+ * Every test here is a refusal or a selection, and both matter for the same reason: this is the
+ * mechanism that makes two people asking one question get the answers their own accounts can see.
+ * The failure that must not exist is a call falling back to somebody else's grant, or to the
+ * deployment's, when the asker has none of their own. That failure is silent by nature — it returns
+ * a plausible answer built from documents the asker cannot open — so it is tested for directly
+ * rather than inferred from the happy path working.
+ *
+ * The vendor is never reached. Every case below is decided before the network, which is itself the
+ * property: a call with no grant behind it must not leave the building.
+ */
+
+const database = createDatabase(
+ process.env.DATABASE_URL ??
+ "postgres://openbot:openbot@localhost:5432/openbot",
+ TEST_POOL,
+);
+
+const suite = randomUUID().slice(0, 8);
+const botId = `agent_oauth_bot_${suite}`;
+const askerId = `user_oauth_asker_${suite}`;
+const otherId = `user_oauth_other_${suite}`;
+const serverId = "google-drive";
+const toolName = "search_files";
+const ref = `${serverId}/${toolName}`;
+
+// 32 zero bytes in base64. A real AES-256 key length, unlike `"x".repeat(44)`, which decodes to 33
+// bytes and makes `importKey` throw — the existing plugin store test gets away with it only because
+// every call there is refused before the vault is ever opened.
+const ENCRYPTION_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+const policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] };
+
+/** The refresh tokens each person's row points at, so a test can tell whose was chosen. */
+const askerRefreshToken = `refresh-token-for-asker-${suite}`;
+const otherRefreshToken = `refresh-token-for-other-${suite}`;
+
+/**
+ * Every token the store decrypted, in order.
+ *
+ * The store hands a token to the MCP client, which is the one thing this test cannot let happen for
+ * real. Recording it at the vault boundary instead answers the only question that matters — whose
+ * secret was about to be sent — without a vendor to send it to.
+ */
+const decrypted: string[] = [];
+
+/** Every refresh token handed to the vendor's token endpoint, in order. */
+const exchanged: string[] = [];
+
+/** The deployment's OAuth client, as the connect flow will eventually write it. */
+const CLIENT = { clientId: "client-id", clientSecret: "client-secret" };
+
+/** What a minted access token looks like, so a test can tell which refresh token produced it. */
+const accessTokenFrom = (refreshToken: string) => `access(${refreshToken})`;
+
+let serverWasAlreadyConfigured = false;
+const credentialIds: string[] = [];
+
+const store = createPluginStore({
+ database,
+ auditStore: createAuditStore(database),
+ credentials: {
+ readSecret: async (id) => {
+ const [row] = await database
+ .select({
+ encryptedValue: credentials.encryptedValue,
+ revokedAt: credentials.revokedAt,
+ })
+ .from(credentials)
+ .where(eq(credentials.id, id));
+ return row ?? null;
+ },
+ },
+ encryptionKey: ENCRYPTION_KEY,
+ policy: () => policy,
+ // Stops before the network, and records what the call would have gone out with.
+ callVendor: async (connection) => {
+ decrypted.push(connection.token ?? "");
+ return { text: "[vendor not reached in tests]", isError: false };
+ },
+ /*
+ * Stands in for Google's token endpoint, and records whose refresh token was presented.
+ *
+ * This is where the security property is observable. The access token that reaches the vendor is
+ * derived from the refresh token that was spent, so asserting on it proves the whole chain picked
+ * one person's grant — rather than proving only that some token was sent.
+ */
+ exchangeRefreshToken: async ({ client, refreshToken }) => {
+ expect(client).toEqual(CLIENT);
+ exchanged.push(refreshToken);
+ return { accessToken: accessTokenFrom(refreshToken) };
+ },
+});
+
+/** Register the deployment's OAuth client, which is what `mcp_servers.credential_id` holds. */
+async function registerClient() {
+ const [credential] = await database
+ .insert(credentials)
+ .values({
+ kind: "mcp_oauth_client",
+ provider: serverId,
+ keyId: "oauth-client",
+ metadata: { clientId: CLIENT.clientId },
+ encryptedValue: await encryptSecret(
+ ENCRYPTION_KEY,
+ JSON.stringify(CLIENT),
+ ),
+ })
+ .returning({ id: credentials.id });
+ if (!credential) throw new Error("client was not stored");
+ credentialIds.push(credential.id);
+ await database
+ .update(mcpServers)
+ .set({ credentialId: credential.id })
+ .where(eq(mcpServers.id, serverId));
+ return credential.id;
+}
+
+async function connect(userId: string, refreshToken: string) {
+ const [credential] = await database
+ .insert(credentials)
+ .values({
+ kind: "mcp_user_token",
+ provider: serverId,
+ keyId: userId,
+ metadata: {},
+ encryptedValue: await encryptSecret(ENCRYPTION_KEY, refreshToken),
+ })
+ .returning({ id: credentials.id });
+ if (!credential) throw new Error("credential was not stored");
+ credentialIds.push(credential.id);
+
+ await database
+ .insert(mcpUserCredentials)
+ .values({
+ serverId,
+ userId,
+ credentialId: credential.id,
+ scope: "https://www.googleapis.com/auth/drive.readonly",
+ })
+ .onConflictDoUpdate({
+ target: [mcpUserCredentials.serverId, mcpUserCredentials.userId],
+ set: { credentialId: credential.id },
+ });
+ return credential.id;
+}
+
+beforeAll(async () => {
+ await database
+ .insert(agents)
+ .values({ id: botId, name: botId, type: "remote_ag_ui", configuration: {} })
+ .onConflictDoNothing();
+
+ for (const [id, email] of [
+ [askerId, `${askerId}@openbot.test`],
+ [otherId, `${otherId}@openbot.test`],
+ ]) {
+ await database
+ .insert(users)
+ .values({ id, email, name: id, emailVerified: false })
+ .onConflictDoNothing();
+ }
+
+ serverWasAlreadyConfigured =
+ (
+ await database
+ .select({ id: mcpServers.id })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, serverId))
+ ).length > 0;
+
+ // Written directly, so the test needs no vendor to be reachable. What is under test is which
+ // credential gets chosen, not the listing.
+ await database
+ .insert(mcpServers)
+ .values({
+ id: serverId,
+ title: "Google Drive",
+ vendor: "Google",
+ url: "https://drivemcp.googleapis.com/mcp/v1",
+ provenance: "first-party",
+ })
+ .onConflictDoNothing();
+ await database
+ .insert(mcpTools)
+ .values({ serverId, name: toolName, description: "Search files." })
+ .onConflictDoNothing();
+
+ // The Bot holds the tool throughout. Everything here is about the person, not the grant.
+ await database
+ .insert(pluginGrants)
+ .values({ kind: "mcp", ref, agentId: botId })
+ .onConflictDoNothing();
+});
+
+afterAll(async () => {
+ await database
+ .delete(mcpUserCredentials)
+ .where(eq(mcpUserCredentials.serverId, serverId));
+ for (const id of credentialIds) {
+ await database.delete(credentials).where(eq(credentials.id, id));
+ }
+ await database.delete(pluginGrants).where(eq(pluginGrants.ref, ref));
+ if (!serverWasAlreadyConfigured) {
+ await database.delete(mcpTools).where(eq(mcpTools.serverId, serverId));
+ await database.delete(mcpServers).where(eq(mcpServers.id, serverId));
+ }
+ await database.delete(agents).where(eq(agents.id, botId));
+ await database.delete(users).where(eq(users.id, askerId));
+ await database.delete(users).where(eq(users.id, otherId));
+});
+
+describe("a person who has not connected", () => {
+ test("is refused, and told to connect rather than told it broke", () => {
+ // A refusal, not an error. Nothing is wrong: they simply have not granted access yet, and the
+ // sentence they get should be one they can act on.
+ expect(
+ store.callTool({ ref, args: {}, botId, actorId: askerId }),
+ ).rejects.toThrow(PluginRefusedError);
+ });
+
+ test("is not quietly served the deployment's own credential", async () => {
+ // The failure this whole table exists to prevent. A fallback here would answer from whatever the
+ // deployment could see and look exactly like a correct answer.
+ await database
+ .update(mcpServers)
+ .set({ credentialId: "a-deployment-credential" })
+ .where(eq(mcpServers.id, serverId));
+
+ await expect(
+ store.callTool({ ref, args: {}, botId, actorId: askerId }),
+ ).rejects.toThrow(PluginRefusedError);
+ expect(decrypted).toEqual([]);
+
+ await database
+ .update(mcpServers)
+ .set({ credentialId: null })
+ .where(eq(mcpServers.id, serverId));
+ });
+});
+
+describe("nobody in particular", () => {
+ test("cannot borrow a connected person's access", async () => {
+ await connect(askerId, askerRefreshToken);
+ decrypted.length = 0;
+
+ // The anonymous actor is the empty string, and an empty string must never match a row. A lookup
+ // that let it through would hand a run nobody is attributable for whichever grant sorted first.
+ await expect(
+ store.callTool({ ref, args: {}, botId, actorId: "" }),
+ ).rejects.toThrow(PluginRefusedError);
+ expect(decrypted).toEqual([]);
+ });
+});
+
+describe("a person who has connected", () => {
+ test("is told plainly when the deployment has registered no client", async () => {
+ // The person did their part and cannot fix this one, so the refusal names the administrator's
+ // job rather than sending them back to try connecting again.
+ await connect(askerId, askerRefreshToken);
+ await database
+ .update(mcpServers)
+ .set({ credentialId: null })
+ .where(eq(mcpServers.id, serverId));
+
+ await expect(
+ store.callTool({ ref, args: {}, botId, actorId: askerId }),
+ ).rejects.toThrow(/OAuth client/);
+ });
+
+ test("goes out with their own token and nobody else's", async () => {
+ await registerClient();
+ await connect(askerId, askerRefreshToken);
+ await connect(otherId, otherRefreshToken);
+ decrypted.length = 0;
+ exchanged.length = 0;
+
+ await store.callTool({ ref, args: {}, botId, actorId: askerId });
+ expect(exchanged).toEqual([askerRefreshToken]);
+ expect(decrypted).toEqual([accessTokenFrom(askerRefreshToken)]);
+
+ decrypted.length = 0;
+ exchanged.length = 0;
+ await store.callTool({ ref, args: {}, botId, actorId: otherId });
+ expect(exchanged).toEqual([otherRefreshToken]);
+ expect(decrypted).toEqual([accessTokenFrom(otherRefreshToken)]);
+ });
+
+ test("never sends the refresh token itself to the vendor", async () => {
+ // The refresh token is long-lived and reauthorises indefinitely; the access token expires. Only
+ // the short-lived one may leave, and a regression here would be invisible in behaviour.
+ await registerClient();
+ await connect(askerId, askerRefreshToken);
+ decrypted.length = 0;
+
+ await store.callTool({ ref, args: {}, botId, actorId: askerId });
+ expect(decrypted).not.toContain(askerRefreshToken);
+ });
+
+ test("is refused once their credential is revoked, and told to reconnect", async () => {
+ const credentialId = await connect(askerId, askerRefreshToken);
+ decrypted.length = 0;
+
+ await database
+ .update(credentials)
+ .set({ revokedAt: new Date() })
+ .where(eq(credentials.id, credentialId));
+
+ /*
+ * A refusal rather than a thrown vendor error.
+ *
+ * The vault already refuses a revoked secret, but it does so by throwing, which reaches the
+ * person as "that tool could not be called" — indistinguishable from the vendor being down. A
+ * withdrawn grant is not a fault, and the sentence should say what to do about it.
+ */
+ await expect(
+ store.callTool({ ref, args: {}, botId, actorId: askerId }),
+ ).rejects.toThrow(PluginRefusedError);
+ expect(decrypted).toEqual([]);
+ });
+
+ test("does not gain access to a server they connected a different one for", async () => {
+ // The lookup is keyed on the pair. A row for one server must not satisfy another.
+ await connect(askerId, askerRefreshToken);
+ decrypted.length = 0;
+
+ await database
+ .delete(mcpUserCredentials)
+ .where(eq(mcpUserCredentials.serverId, serverId));
+
+ await expect(
+ store.callTool({ ref, args: {}, botId, actorId: askerId }),
+ ).rejects.toThrow(PluginRefusedError);
+ expect(decrypted).toEqual([]);
+ });
+});
From 3a784f98804e524347d2dcf144d837469ab87d30 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Thu, 20 Aug 2026 15:44:57 -0300
Subject: [PATCH 06/34] Let a person connect their own account, and believe
only what we signed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The half of the flow that leaves this deployment and comes back. An administrator
registers the deployment's OAuth client once; each person then consents for
themselves and their refresh token becomes the row the call path already looks for.
The browser being in the middle is the whole difficulty. An authorization code
arrives on a request that somebody else's server sent the person to, so nothing on
it can be believed alone — not who is connecting, not which server they meant, not
that they ever asked. Two things carry that across: a state this deployment signed,
and a PKCE verifier proving the code being redeemed belongs to the request that
started it.
So `oauth.ts` is mostly refusal, and its tests are mostly refusal. A state that was
tampered with, signed with another key, replayed after expiry, stripped of its
signature, or malformed all read back as null — one answer, because a caller that
has to tell those apart is a caller that can get one of them wrong. The reason it
matters is specific: the alternative is attaching one person's Google account to
another person's row.
Signed under its own label, so a connect state can never be a run assertion wearing
a different hat, and no other signed value this deployment hands out is a candidate.
The callback is deliberately not behind `requireUser`. Whose connection this is comes
from the state, never from whatever session the browser happens to be carrying —
which is what stops a callback delivered to the wrong browser from writing to the
wrong person. Every failure ends identically, back at Settings with nothing written:
there is no useful distinction for the person between a forged state and an expired
one, and naming which is which tells anybody probing the endpoint how far they got.
Two request parameters are load bearing and easy to lose. Without
`access_type=offline` Google returns no refresh token, so a connection would appear
to work and stop about an hour later with nothing to renew it. Without
`prompt=consent` a second connect returns no refresh token either, because the
person already agreed once — which would turn reconnecting after a disconnect into a
silent no-op. Both are asserted. So is the absence of a refresh token in the
response being treated as failure rather than partial success, for the same reason:
storing the access token instead produces a connection that looks like success.
The redirect URI is built from configured `publicUrl` (`OPENBOT_PUBLIC_URL`, falling
back to `BETTER_AUTH_URL`) rather than the incoming request. It has to match what was
registered with the vendor character for character, and one assembled out of a Host
header is one an attacker has a say in. A deployment without it refuses to start a
flow and says so. The value is served to the Plugins page so an administrator can
copy exactly what we will send.
Registering a client and recording a connection both write the vault themselves
rather than having the browser post `/api/admin/credentials` first, which is what the
narrowed allowlist in the previous commit anticipated: the first of two calls can
succeed and the second fail, leaving a secret nothing points at and nobody knows to
revoke. Both replace-and-revoke rather than accumulate — a refresh token nothing
points at is still a live grant at the vendor, and leaving it would give a person who
reconnected two valid grants and only one of them visible to disconnect.
Also found while here: `server/tsconfig.json` includes only `src`, so server tests
are never typechecked. Widening the store's vault type broke two test files at the
type level in total silence. The two are fixed and their stubs now throw rather than
no-op, but the gap is real — including `tests` surfaces 34 pre-existing errors, which
is its own piece of work and not this one.
Nothing in the UI reaches any of this yet.
---
server/src/app.ts | 8 +-
server/src/audit.ts | 17 ++
server/src/config.ts | 19 +-
server/src/plugins/oauth.ts | 212 ++++++++++++++++++
server/src/plugins/routes.ts | 182 ++++++++++++++-
server/src/plugins/store.ts | 194 +++++++++++++++-
server/tests/plugin-oauth.test.ts | 171 ++++++++++++++
server/tests/plugin-store.integration.test.ts | 8 +
...plugin-user-credential.integration.test.ts | 8 +
9 files changed, 815 insertions(+), 4 deletions(-)
create mode 100644 server/src/plugins/oauth.ts
create mode 100644 server/tests/plugin-oauth.test.ts
diff --git a/server/src/app.ts b/server/src/app.ts
index 2423be6b..e6f3774b 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -306,7 +306,13 @@ export function createApp(
}
if (pluginStore) {
- app.route("/api/plugins", createPluginRoutes(pluginStore, requireUser));
+ app.route(
+ "/api/plugins",
+ createPluginRoutes(pluginStore, requireUser, {
+ encryptionKey: config.keyEncryptionKey,
+ publicUrl: config.publicUrl,
+ }),
+ );
}
/*
diff --git a/server/src/audit.ts b/server/src/audit.ts
index d72250e7..700d981e 100644
--- a/server/src/audit.ts
+++ b/server/src/audit.ts
@@ -57,6 +57,23 @@ export const auditEventTypes = [
"agent.stream_stalled",
"mcp.call_succeeded",
"mcp.call_rejected",
+ /*
+ * An administrator registered this deployment's OAuth client with a vendor.
+ *
+ * Recorded because it decides what every subsequent consent screen belongs to. If a client is
+ * replaced, every person who connects afterwards is granting access to a different registration,
+ * and the row is what lets somebody reading the trail line a connection up against the client that
+ * was current when it was made. The client id, never the secret.
+ */
+ "mcp.oauth_client_registered",
+ /*
+ * One person connected their own account to one server.
+ *
+ * Its own row rather than a credential event, because what happened is not "a secret was stored" —
+ * it is a person granting a deployment continuing access to their documents, which is the kind of
+ * thing they are entitled to see a record of. Carries the scope the vendor actually granted.
+ */
+ "mcp.account_connected",
// Every action a Bot takes on its computer, allowed or refused. Both, always: a trail that records
// only what was permitted cannot answer whether the Bot tried.
"computer.action_allowed",
diff --git a/server/src/config.ts b/server/src/config.ts
index a85e8f01..09cc6550 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -32,6 +32,19 @@ export type DeploymentConfig = {
* packages but not a copy of one running alongside the original. See channels/thread-identity.ts.
*/
deploymentId: string | undefined;
+ /**
+ * Where this deployment is reached from outside, with no trailing slash.
+ *
+ * Needed because an OAuth redirect URI has to match what an administrator registered with the
+ * vendor character for character, and it is shown on the Plugins page for them to copy. Built from
+ * configuration rather than from the incoming request: a redirect URI assembled out of a Host
+ * header is one an attacker has a say in.
+ *
+ * `OPENBOT_PUBLIC_URL` when set, otherwise `BETTER_AUTH_URL`, which is the same public address for
+ * every deployment that has real sign-in. Undefined only where neither exists, which is a local
+ * deployment running without authentication — and there is nothing to connect there anyway.
+ */
+ publicUrl: string | undefined;
tenantPackageDirectory: string;
runtime: RuntimeCapabilities;
/**
@@ -364,6 +377,7 @@ export function loadConfig(
environment: Environment = process.env,
): DeploymentConfig {
const google = oauthClient(environment, "GOOGLE");
+ const auth = authConfig(environment, google);
return {
databaseUrl: required(environment, "DATABASE_URL"),
@@ -373,12 +387,15 @@ export function loadConfig(
"MANAGED_AGENT_AG_UI_URL",
),
deploymentId: optional(environment, "DEPLOYMENT_ID"),
+ publicUrl: (
+ optional(environment, "OPENBOT_PUBLIC_URL") ?? auth?.baseUrl
+ )?.replace(/\/+$/, ""),
tenantPackageDirectory:
optional(environment, "TENANT_PACKAGE_DIR") ?? "../examples/fintech",
runtime: runtimeCapabilities(environment),
agentStallTimeoutMs: agentStallTimeoutMs(environment),
oauth: { google },
- auth: authConfig(environment, google),
+ auth,
devNoAuth: devAuthEnabled(environment),
computer: computerConfig(environment),
...(optional(environment, "AGENT_TOOL_TOKEN")
diff --git a/server/src/plugins/oauth.ts b/server/src/plugins/oauth.ts
new file mode 100644
index 00000000..ac043320
--- /dev/null
+++ b/server/src/plugins/oauth.ts
@@ -0,0 +1,212 @@
+import { createHash, randomBytes } from "node:crypto";
+import { sign, verify } from "../auth/signed-value";
+import type { CatalogueAuth } from "./catalogue";
+
+/**
+ * The connect flow: sending a person to a vendor to consent, and believing what comes back.
+ *
+ * The browser is in the middle of this, which is the whole difficulty. An authorization code arrives
+ * on a request that somebody else's server sent the person to, so nothing on it can be believed on
+ * its own — not who is connecting, not which server they meant, not that they ever asked. Two things
+ * carry the truth across: a signed state, which is this deployment's own statement about the request
+ * it started, and a PKCE verifier, which proves the code being redeemed belongs to that request.
+ *
+ * Everything here fails closed. A state that was tampered with, replayed after it expired, or minted
+ * for some other purpose reads back as nothing, because the alternative is attaching one person's
+ * Google account to another person's row.
+ */
+
+/**
+ * The label this deployment's connect states are signed under.
+ *
+ * Its own, so a signature valid here can never be replayed as a run assertion and vice versa. Every
+ * signed value the deployment hands out would otherwise be a candidate state.
+ */
+const CONNECT_LABEL = "mcp-oauth-connect";
+
+/**
+ * How long somebody has to finish consenting.
+ *
+ * Long enough to read a consent screen and pick an account, short enough that a link left in a tab
+ * overnight is not still redeemable. The state carries no permission by itself, but it does say who
+ * the resulting grant gets attached to, which is worth keeping fresh.
+ */
+const STATE_TTL_MS = 10 * 60_000;
+
+/** The one path a vendor is ever told to send somebody back to. */
+const CALLBACK_PATH = "/api/plugins/oauth/callback";
+
+export type ConnectState = {
+ /** Who is connecting. Taken from their session when the flow starts, never from the callback. */
+ userId: string;
+ /** Which server they are connecting. Prevents a code for one vendor landing on another's row. */
+ serverId: string;
+ /** The PKCE verifier, held here rather than in a table because it is single-use and short-lived. */
+ verifier: string;
+};
+
+type SignedState = ConnectState & { exp: number };
+
+/**
+ * Where the vendor sends somebody back to.
+ *
+ * Built from the deployment's own public URL rather than from the incoming request, because this
+ * value has to match what an administrator registered with the vendor character for character. A
+ * redirect URI assembled from a request header is a redirect URI an attacker has a say in.
+ */
+export function redirectUriFor(publicUrl: string): string {
+ return `${publicUrl.replace(/\/+$/, "")}${CALLBACK_PATH}`;
+}
+
+/** A fresh PKCE verifier: unreserved characters only, comfortably over the 43-character floor. */
+export function createVerifier(): string {
+ return randomBytes(48).toString("base64url");
+}
+
+/** The S256 challenge for a verifier. Never `plain`, which would make the challenge worthless. */
+export function challengeFor(verifier: string): string {
+ return createHash("sha256").update(verifier).digest("base64url");
+}
+
+export function signConnectState(
+ state: ConnectState,
+ encryptionKey: string,
+ now: number = Date.now(),
+): string {
+ const payload: SignedState = { ...state, exp: now + STATE_TTL_MS };
+ const value = Buffer.from(JSON.stringify(payload)).toString("base64url");
+ return sign(value, encryptionKey, CONNECT_LABEL);
+}
+
+/**
+ * What a state says, or nothing at all.
+ *
+ * One return for every way of being unacceptable — bad signature, wrong label, expired, malformed,
+ * missing a field — because a caller that has to tell those apart is a caller that can get one of
+ * them wrong. There is exactly one thing to do with an unusable state, so there is one answer.
+ */
+export function readConnectState(
+ signed: string,
+ encryptionKey: string,
+ now: number = Date.now(),
+): ConnectState | null {
+ const value = verify(signed, encryptionKey, CONNECT_LABEL);
+ if (!value) return null;
+
+ try {
+ const payload = JSON.parse(
+ Buffer.from(value, "base64url").toString("utf8"),
+ ) as Partial;
+
+ if (
+ typeof payload.userId !== "string" ||
+ !payload.userId ||
+ typeof payload.serverId !== "string" ||
+ !payload.serverId ||
+ typeof payload.verifier !== "string" ||
+ !payload.verifier ||
+ typeof payload.exp !== "number" ||
+ payload.exp <= now
+ ) {
+ return null;
+ }
+
+ return {
+ userId: payload.userId,
+ serverId: payload.serverId,
+ verifier: payload.verifier,
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The vendor's consent screen, as a URL to send somebody to.
+ *
+ * `offline` and `consent` are both load bearing. Without `access_type=offline` Google returns an
+ * access token and no refresh token, so the connection would appear to work and then stop about an
+ * hour later with nothing to renew it. Without `prompt=consent` a second connect returns no refresh
+ * token at all, because the person already agreed once — which turns reconnecting after a disconnect
+ * into a silent no-op.
+ */
+export function authorizationUrlFor(input: {
+ auth: Extract;
+ clientId: string;
+ redirectUri: string;
+ state: string;
+ codeChallenge: string;
+}): string {
+ const url = new URL(input.auth.authorizationUrl);
+ url.search = new URLSearchParams({
+ client_id: input.clientId,
+ redirect_uri: input.redirectUri,
+ response_type: "code",
+ scope: input.auth.scopes.join(" "),
+ access_type: "offline",
+ prompt: "consent",
+ state: input.state,
+ code_challenge: input.codeChallenge,
+ code_challenge_method: "S256",
+ }).toString();
+ return url.toString();
+}
+
+export type RedeemedGrant = {
+ refreshToken: string;
+ /** What the vendor actually granted, which is not always what was asked for. */
+ scope: string;
+};
+
+/**
+ * Trade an authorization code for the refresh token that stands in for somebody's access.
+ *
+ * A refusal rather than an exception when the vendor declines, because the most likely causes are
+ * ordinary: a redirect URI that does not match what was registered, or somebody taking too long. The
+ * vendor's own error body is not passed through — it is written for whoever registered the client and
+ * can name the client id.
+ */
+export async function redeemAuthorizationCode(input: {
+ tokenUrl: string;
+ clientId: string;
+ clientSecret: string;
+ code: string;
+ redirectUri: string;
+ verifier: string;
+}): Promise {
+ const response = await fetch(input.tokenUrl, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code: input.code,
+ client_id: input.clientId,
+ client_secret: input.clientSecret,
+ redirect_uri: input.redirectUri,
+ code_verifier: input.verifier,
+ }),
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!response.ok) return null;
+
+ const body = (await response.json()) as {
+ refresh_token?: unknown;
+ scope?: unknown;
+ };
+ /*
+ * No refresh token is a failure, not a partial success.
+ *
+ * It is what a vendor returns when it believes this person already consented, and storing the
+ * access token instead would produce a connection that works for an hour and then cannot be
+ * renewed — the worst of the three outcomes, because it looks like success.
+ */
+ if (typeof body.refresh_token !== "string" || !body.refresh_token) {
+ return null;
+ }
+
+ return {
+ refreshToken: body.refresh_token,
+ scope: typeof body.scope === "string" ? body.scope : "",
+ };
+}
diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts
index c09b5efd..d2f07cb1 100644
--- a/server/src/plugins/routes.ts
+++ b/server/src/plugins/routes.ts
@@ -2,7 +2,16 @@ import type { MiddlewareHandler } from "hono";
import { Hono } from "hono";
import type { AppVariables } from "../auth/guards";
import { requireAdmin } from "../auth/guards";
-import { CATALOGUE } from "./catalogue";
+import { CATALOGUE, catalogueEntry } from "./catalogue";
+import {
+ authorizationUrlFor,
+ challengeFor,
+ createVerifier,
+ readConnectState,
+ redeemAuthorizationCode,
+ redirectUriFor,
+ signConnectState,
+} from "./oauth";
import {
CatalogueEntryUnknownError,
CustomServerRefusedError,
@@ -30,6 +39,14 @@ import {
export function createPluginRoutes(
store: PluginStore,
requireUser: MiddlewareHandler<{ Variables: AppVariables }>,
+ /**
+ * What the connect flow needs that the store does not hold: the key its state is signed with, and
+ * the address a vendor sends people back to.
+ *
+ * Optional, so a deployment with no public URL configured simply cannot start a connect flow and
+ * says so, rather than building a redirect URI out of a request header and failing at the vendor.
+ */
+ connect?: { encryptionKey: string; publicUrl: string | undefined },
) {
const routes = new Hono<{ Variables: AppVariables }>();
@@ -159,6 +176,50 @@ export function createPluginRoutes(
}
});
+ /**
+ * Register this deployment's OAuth client for a server reached as the person asking.
+ *
+ * Its own endpoint rather than a field on `POST /servers`, because it is a separate act with a
+ * separate lifetime: a client is rotated without the server being re-added, and re-adding a server
+ * should not require re-typing a client. An administrator's, like everything else that decides what
+ * a Bot can reach.
+ */
+ routes.post("/servers/:id/oauth-client", requireUser, async (context) => {
+ const forbidden = requireAdmin(context);
+ if (forbidden) return forbidden;
+
+ const body = (await context.req.json().catch(() => null)) as {
+ clientId?: string;
+ clientSecret?: string;
+ } | null;
+ if (!body?.clientId?.trim() || !body.clientSecret?.trim()) {
+ return context.json(
+ { error: "A client id and a client secret are both required." },
+ 400,
+ );
+ }
+
+ try {
+ await store.registerOAuthClient({
+ serverId: context.req.param("id"),
+ client: {
+ clientId: body.clientId.trim(),
+ clientSecret: body.clientSecret.trim(),
+ },
+ by: actorEmail(context),
+ });
+ return context.json({ ok: true });
+ } catch (error) {
+ if (
+ error instanceof CatalogueEntryUnknownError ||
+ error instanceof CustomServerRefusedError
+ ) {
+ return context.json({ error: error.message }, 400);
+ }
+ throw error;
+ }
+ });
+
routes.delete("/servers/:id", requireUser, async (context) => {
const forbidden = requireAdmin(context);
if (forbidden) return forbidden;
@@ -190,6 +251,125 @@ export function createPluginRoutes(
}
});
+ /**
+ * Where a person's own connections are, and how to start a new one.
+ *
+ * Not admin-only, and that is the point: an administrator registers the connector once, and then
+ * everybody connects their own account. Somebody can only ever see or start their own.
+ */
+ routes.get("/connections", requireUser, async (context) => {
+ const connections = await store.connectionsFor(context.var.actor.id);
+ return context.json({
+ connections,
+ // Shown to an administrator so they can register the client at the vendor with the exact value
+ // this deployment will send. Null means the deployment has no public URL and cannot connect.
+ redirectUri: connect?.publicUrl
+ ? redirectUriFor(connect.publicUrl)
+ : null,
+ });
+ });
+
+ /**
+ * Begin connecting one person's own account.
+ *
+ * Answers with a URL rather than redirecting, so the browser decides when to leave the page. The
+ * state is minted here, from the session, and the person's identity never comes off the callback.
+ */
+ routes.post("/servers/:id/connect", requireUser, async (context) => {
+ const serverId = context.req.param("id");
+ if (!connect?.publicUrl) {
+ return context.json(
+ {
+ error:
+ "This deployment has no public URL configured, so it cannot complete a consent flow. Set OPENBOT_PUBLIC_URL.",
+ },
+ 503,
+ );
+ }
+
+ const entry = catalogueEntry(serverId);
+ if (entry?.auth.kind !== "user-oauth") {
+ return context.json(
+ { error: `${serverId} is not connected as an individual person.` },
+ 400,
+ );
+ }
+
+ const client = await store.oauthClientFor(serverId);
+ if (!client) {
+ return context.json(
+ {
+ error: `${entry.title} has no OAuth client registered yet. An administrator has to add one first.`,
+ },
+ 409,
+ );
+ }
+
+ const verifier = createVerifier();
+ return context.json({
+ authorizationUrl: authorizationUrlFor({
+ auth: entry.auth,
+ clientId: client.clientId,
+ redirectUri: redirectUriFor(connect.publicUrl),
+ state: signConnectState(
+ { userId: context.var.actor.id, serverId, verifier },
+ connect.encryptionKey,
+ ),
+ codeChallenge: challengeFor(verifier),
+ }),
+ });
+ });
+
+ /**
+ * Where the vendor sends somebody back.
+ *
+ * Deliberately not behind `requireUser`. The person arrives on a redirect from another company's
+ * server, and whose connection this is comes from the signed state rather than from whatever
+ * session the browser happens to be carrying — which is what stops a callback delivered to the
+ * wrong browser from attaching one person's Google account to another person's row.
+ *
+ * Every failure ends the same way: back at Settings with a word about what happened, and nothing
+ * written. There is no useful distinction here for the person between a forged state and an expired
+ * one, and spelling out which is which tells anybody probing this endpoint how far they got.
+ */
+ routes.get("/oauth/callback", async (context) => {
+ const settings = "/settings";
+ const failed = `${settings}?connected=failed`;
+ if (!connect?.publicUrl) return context.redirect(failed);
+
+ const code = context.req.query("code");
+ const state = readConnectState(
+ context.req.query("state") ?? "",
+ connect.encryptionKey,
+ );
+ if (!code || !state) return context.redirect(failed);
+
+ const entry = catalogueEntry(state.serverId);
+ if (entry?.auth.kind !== "user-oauth") return context.redirect(failed);
+
+ const client = await store.oauthClientFor(state.serverId);
+ if (!client) return context.redirect(failed);
+
+ const grant = await redeemAuthorizationCode({
+ tokenUrl: entry.auth.tokenUrl,
+ clientId: client.clientId,
+ clientSecret: client.clientSecret,
+ code,
+ redirectUri: redirectUriFor(connect.publicUrl),
+ verifier: state.verifier,
+ });
+ if (!grant) return context.redirect(failed);
+
+ await store.recordConnection({
+ serverId: state.serverId,
+ userId: state.userId,
+ refreshToken: grant.refreshToken,
+ scope: grant.scope,
+ });
+
+ return context.redirect(`${settings}?connected=${state.serverId}`);
+ });
+
/**
* Write a skill.
*
diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts
index 6840d9bd..8191f46d 100644
--- a/server/src/plugins/store.ts
+++ b/server/src/plugins/store.ts
@@ -7,7 +7,9 @@ import {
} from "../computer/policy";
import {
type CredentialSecretReader,
+ type CredentialStore,
decryptCredentialForUse,
+ encryptSecret,
} from "../credentials";
import type { Database } from "../db/client";
import {
@@ -220,7 +222,15 @@ export type AccessToken = { accessToken: string; expiresInSeconds?: number };
export type PluginStoreOptions = {
database: Database;
auditStore: AuditStore;
- credentials: CredentialSecretReader;
+ /**
+ * The vault, read and write.
+ *
+ * Writing is here rather than left to the browser posting `/api/admin/credentials` first. An OAuth
+ * client belongs to the server registration and a refresh token belongs to a connection, so both
+ * are written by the code that owns those acts — otherwise the first of two calls can succeed and
+ * the second fail, leaving a secret in the vault that nothing points at and nobody knows to revoke.
+ */
+ credentials: CredentialSecretReader & CredentialStore;
encryptionKey: string;
/** Read at call time, never captured, so a policy changed a moment ago applies to this call. */
policy: () => ActionPolicy;
@@ -905,6 +915,188 @@ export function createPluginStore(options: PluginStoreOptions) {
};
},
+ /**
+ * Register the deployment's OAuth client for a `user-oauth` server.
+ *
+ * Both halves go into one encrypted value, so a single vault read yields a usable client. The id
+ * is copied into `metadata` as well — it is not a secret, and a page listing what the deployment
+ * holds should be able to name it without decrypting anything.
+ *
+ * Replacing a client revokes the previous one rather than orphaning it, so "what does this
+ * deployment hold" keeps having one answer per server. Nobody's connection breaks: a refresh
+ * token is the person's, and it is the client that is being rotated underneath it.
+ */
+ async registerOAuthClient(input: {
+ serverId: string;
+ client: OAuthClient;
+ by: string;
+ }): Promise {
+ const { row, entry } = await requireServer(input.serverId);
+ if (entry?.auth.kind !== "user-oauth") {
+ throw new CustomServerRefusedError(
+ `${input.serverId} is not reached with an OAuth client.`,
+ );
+ }
+
+ const stored = await credentials.create({
+ kind: "mcp_oauth_client",
+ provider: input.serverId,
+ keyId: `oauth-client-${input.serverId}`,
+ metadata: { server: input.serverId, clientId: input.client.clientId },
+ encryptedValue: await encryptSecret(
+ encryptionKey,
+ JSON.stringify(input.client),
+ ),
+ });
+
+ await database
+ .update(mcpServers)
+ .set({ credentialId: stored.id, updatedAt: new Date() })
+ .where(eq(mcpServers.id, input.serverId));
+
+ if (row.credentialId) {
+ await credentials.revoke(row.credentialId).catch(() => {
+ // A previous client that cannot be revoked must not stop the new one taking effect. The
+ // pointer has already moved, so nothing reaches the old row; it is a tidiness failure.
+ });
+ }
+
+ await recordAuditEvent(auditStore, {
+ eventType: "mcp.oauth_client_registered",
+ targetType: "mcp_server",
+ targetId: input.serverId,
+ payload: {
+ actor: input.by,
+ server: input.serverId,
+ // The id, never the secret. It identifies the client an administrator registered, which is
+ // what somebody reading the trail needs in order to check it against the vendor's console.
+ clientId: input.client.clientId,
+ replaced: row.credentialId !== null,
+ },
+ });
+ },
+
+ /**
+ * Record that one person connected their own account to one server.
+ *
+ * Upserted on the pair, so reconnecting replaces rather than accumulating. The credential the row
+ * used to point at is revoked in the same breath: a refresh token nothing points at is still a
+ * live grant at the vendor, and leaving it behind would mean a person who reconnected had two
+ * valid grants and could only ever see one of them to disconnect it.
+ */
+ async recordConnection(input: {
+ serverId: string;
+ userId: string;
+ refreshToken: string;
+ scope: string;
+ }): Promise {
+ const [previous] = await database
+ .select({ credentialId: mcpUserCredentials.credentialId })
+ .from(mcpUserCredentials)
+ .where(
+ and(
+ eq(mcpUserCredentials.serverId, input.serverId),
+ eq(mcpUserCredentials.userId, input.userId),
+ ),
+ )
+ .limit(1);
+
+ const stored = await credentials.create({
+ kind: "mcp_user_token",
+ provider: input.serverId,
+ keyId: input.userId,
+ metadata: { server: input.serverId, scope: input.scope },
+ encryptedValue: await encryptSecret(encryptionKey, input.refreshToken),
+ });
+
+ await database
+ .insert(mcpUserCredentials)
+ .values({
+ serverId: input.serverId,
+ userId: input.userId,
+ credentialId: stored.id,
+ scope: input.scope,
+ })
+ .onConflictDoUpdate({
+ target: [mcpUserCredentials.serverId, mcpUserCredentials.userId],
+ set: {
+ credentialId: stored.id,
+ scope: input.scope,
+ updatedAt: new Date(),
+ },
+ });
+
+ if (previous) {
+ await credentials.revoke(previous.credentialId).catch(() => {
+ // Same reasoning as above: the pointer has moved, so this is tidiness rather than access.
+ });
+ }
+
+ await recordAuditEvent(auditStore, {
+ eventType: "mcp.account_connected",
+ targetType: "mcp_server",
+ targetId: input.serverId,
+ payload: {
+ actor: input.userId,
+ server: input.serverId,
+ // What the vendor granted, so a later refusal for want of a scope can be explained.
+ scope: input.scope,
+ reconnected: previous !== undefined,
+ },
+ });
+ },
+
+ /**
+ * The deployment's OAuth client for a server, or null if none is registered.
+ *
+ * Decrypted, because both halves are needed: the id to build a consent URL and the secret to
+ * redeem the code it comes back with. Held for the length of one request, like every other
+ * secret this module reads.
+ */
+ async oauthClientFor(serverId: string): Promise {
+ const [row] = await database
+ .select({ credentialId: mcpServers.credentialId })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, serverId))
+ .limit(1);
+ if (!row?.credentialId) return null;
+
+ try {
+ return JSON.parse(
+ await decryptCredentialForUse(
+ encryptionKey,
+ credentials,
+ row.credentialId,
+ ),
+ ) as OAuthClient;
+ } catch {
+ // A revoked, missing or unreadable client is the same as none for every caller: there is
+ // nothing to send anybody to consent with, and the answer is for an administrator to add one.
+ return null;
+ }
+ },
+
+ /** Which `user-oauth` servers this person has connected, for their own settings page. */
+ async connectionsFor(
+ userId: string,
+ ): Promise<{ serverId: string; scope: string; connectedAt: string }[]> {
+ const rows = await database
+ .select({
+ serverId: mcpUserCredentials.serverId,
+ scope: mcpUserCredentials.scope,
+ connectedAt: mcpUserCredentials.connectedAt,
+ })
+ .from(mcpUserCredentials)
+ .where(eq(mcpUserCredentials.userId, userId))
+ .orderBy(asc(mcpUserCredentials.serverId));
+
+ return rows.map((row) => ({
+ serverId: row.serverId,
+ scope: row.scope,
+ connectedAt: iso(row.connectedAt) ?? "",
+ }));
+ },
+
/**
* May this Bot use this plugin?
*
diff --git a/server/tests/plugin-oauth.test.ts b/server/tests/plugin-oauth.test.ts
new file mode 100644
index 00000000..bf01d168
--- /dev/null
+++ b/server/tests/plugin-oauth.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, test } from "bun:test";
+import { catalogueEntry } from "../src/plugins/catalogue";
+import {
+ authorizationUrlFor,
+ challengeFor,
+ createVerifier,
+ readConnectState,
+ redirectUriFor,
+ signConnectState,
+} from "../src/plugins/oauth";
+
+/**
+ * The half of the connect flow that leaves this deployment and comes back.
+ *
+ * Everything here exists because the browser is in the middle of it. An authorization code arrives
+ * on a URL somebody else's server sent the person to, so nothing on that request can be believed on
+ * its own: not who is connecting, not which server they meant, and not that they ever asked. The
+ * signed state is what carries those facts across, and the PKCE verifier is what proves the code
+ * being redeemed belongs to the request that started it.
+ *
+ * So these tests are almost entirely about refusal. A state that was tampered with, replayed after
+ * expiry, or minted for another purpose has to come back as nothing, because the alternative is
+ * attaching somebody else's Google account to this person's row.
+ */
+
+const KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+const drive = catalogueEntry("google-drive");
+if (drive?.auth.kind !== "user-oauth") {
+ throw new Error("google-drive must be a user-oauth entry for these tests");
+}
+const driveAuth = drive.auth;
+
+const NOW = 1_770_000_000_000;
+
+describe("the state that travels through the vendor", () => {
+ test("carries who, which server, and the verifier, and reads back exactly", () => {
+ const signed = signConnectState(
+ { userId: "user-1", serverId: "google-drive", verifier: "v-1" },
+ KEY,
+ NOW,
+ );
+ expect(readConnectState(signed, KEY, NOW)).toEqual({
+ userId: "user-1",
+ serverId: "google-drive",
+ verifier: "v-1",
+ });
+ });
+
+ test("is refused once a character of it changes", () => {
+ const signed = signConnectState(
+ { userId: "user-1", serverId: "google-drive", verifier: "v-1" },
+ KEY,
+ NOW,
+ );
+ // The payload is base64url, so flipping a character inside it is the realistic tamper: somebody
+ // trying to have the callback attach their Google account to another person's row.
+ const tampered = `${signed.slice(0, 4)}${signed[4] === "A" ? "B" : "A"}${signed.slice(5)}`;
+ expect(readConnectState(tampered, KEY, NOW)).toBeNull();
+ });
+
+ test("is refused when signed with a different key", () => {
+ const signed = signConnectState(
+ { userId: "user-1", serverId: "google-drive", verifier: "v-1" },
+ "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
+ NOW,
+ );
+ expect(readConnectState(signed, KEY, NOW)).toBeNull();
+ });
+
+ test("expires, so a stale consent screen cannot be redeemed later", () => {
+ const signed = signConnectState(
+ { userId: "user-1", serverId: "google-drive", verifier: "v-1" },
+ KEY,
+ NOW,
+ );
+ expect(readConnectState(signed, KEY, NOW + 60_000)).not.toBeNull();
+ expect(readConnectState(signed, KEY, NOW + 60 * 60_000)).toBeNull();
+ });
+
+ test("cannot be a run assertion wearing a different hat", () => {
+ // Signed under its own label, so a signature valid for one kind of statement is not valid as
+ // another. Without that, any signed value this deployment ever hands out is a candidate state.
+ const signed = signConnectState(
+ { userId: "user-1", serverId: "google-drive", verifier: "v-1" },
+ KEY,
+ NOW,
+ );
+ const [payload] = signed.split(".");
+ expect(readConnectState(payload ?? "", KEY, NOW)).toBeNull();
+ });
+
+ test("is refused when it is not a state at all", () => {
+ expect(readConnectState("", KEY, NOW)).toBeNull();
+ expect(readConnectState("nonsense", KEY, NOW)).toBeNull();
+ expect(readConnectState("a.b", KEY, NOW)).toBeNull();
+ });
+});
+
+describe("PKCE", () => {
+ test("a verifier is long enough and URL-safe", () => {
+ const verifier = createVerifier();
+ // RFC 7636 puts the floor at 43 characters, and the alphabet is unreserved characters only.
+ expect(verifier.length).toBeGreaterThanOrEqual(43);
+ expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
+ });
+
+ test("two verifiers are not the same", () => {
+ expect(createVerifier()).not.toBe(createVerifier());
+ });
+
+ test("a challenge is the S256 of the verifier, not the verifier", () => {
+ // `plain` would make the challenge worthless: anybody who intercepted the authorization request
+ // would hold the value needed to redeem the code.
+ const verifier = "a".repeat(43);
+ const challenge = challengeFor(verifier);
+ expect(challenge).not.toBe(verifier);
+ expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/);
+ expect(challengeFor(verifier)).toBe(challenge);
+ });
+});
+
+describe("the address the person is sent to", () => {
+ const url = new URL(
+ authorizationUrlFor({
+ auth: driveAuth,
+ clientId: "client-id",
+ redirectUri: "https://openbot.example/api/plugins/oauth/callback",
+ state: "signed-state",
+ codeChallenge: "challenge",
+ }),
+ );
+
+ test("is the vendor's own, from the catalogue", () => {
+ expect(`${url.origin}${url.pathname}`).toBe(driveAuth.authorizationUrl);
+ });
+
+ test("asks for a refresh token that consent is granted for once", () => {
+ // Without `offline`, Google returns an access token and no refresh token, and the connection
+ // would silently stop working an hour later. `consent` is what makes it re-issue a refresh token
+ // rather than returning nothing on a second connect.
+ expect(url.searchParams.get("access_type")).toBe("offline");
+ expect(url.searchParams.get("prompt")).toBe("consent");
+ expect(url.searchParams.get("response_type")).toBe("code");
+ });
+
+ test("asks only for the scopes the entry pins", () => {
+ expect(url.searchParams.get("scope")).toBe(driveAuth.scopes.join(" "));
+ });
+
+ test("carries the state and the challenge, and names the method", () => {
+ expect(url.searchParams.get("state")).toBe("signed-state");
+ expect(url.searchParams.get("code_challenge")).toBe("challenge");
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
+ });
+});
+
+describe("the address the vendor sends them back to", () => {
+ test("is one path, built from the deployment's own public URL", () => {
+ expect(redirectUriFor("https://openbot.example")).toBe(
+ "https://openbot.example/api/plugins/oauth/callback",
+ );
+ });
+
+ test("does not double a slash when the public URL has a trailing one", () => {
+ // A redirect URI has to match what was registered with the vendor character for character, so a
+ // stray slash is not cosmetic: it fails at the vendor, with a message that does not name us.
+ expect(redirectUriFor("https://openbot.example/")).toBe(
+ "https://openbot.example/api/plugins/oauth/callback",
+ );
+ });
+});
diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts
index 3349ab8e..424764ee 100644
--- a/server/tests/plugin-store.integration.test.ts
+++ b/server/tests/plugin-store.integration.test.ts
@@ -53,6 +53,14 @@ const store = createPluginStore({
credentials: {
// No credential is ever read in these tests, because every call is refused before the vault.
readSecret: async () => null,
+ // Nor written. Loud rather than absent: a call reaching either of these would mean this file had
+ // started exercising something it does not claim to, and a silent no-op would hide that.
+ create: async () => {
+ throw new Error("this suite does not write credentials");
+ },
+ revoke: async () => {
+ throw new Error("this suite does not revoke credentials");
+ },
},
encryptionKey: "x".repeat(44),
policy: () => policy,
diff --git a/server/tests/plugin-user-credential.integration.test.ts b/server/tests/plugin-user-credential.integration.test.ts
index 5cc6ce45..69ba8935 100644
--- a/server/tests/plugin-user-credential.integration.test.ts
+++ b/server/tests/plugin-user-credential.integration.test.ts
@@ -90,6 +90,14 @@ const store = createPluginStore({
.where(eq(credentials.id, id));
return row ?? null;
},
+ // This suite writes its rows directly, so that what is under test is the selection rather than
+ // the connect flow. Loud rather than absent, so a call here shows up instead of passing quietly.
+ create: async () => {
+ throw new Error("this suite writes credentials directly");
+ },
+ revoke: async () => {
+ throw new Error("this suite does not revoke credentials");
+ },
},
encryptionKey: ENCRYPTION_KEY,
policy: () => policy,
From 7dcdf5e5e38df486ecb12cc632de6297fa701132 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Thu, 20 Aug 2026 15:48:23 -0300
Subject: [PATCH 07/34] Give the two halves of a connector the two screens they
belong on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The shape of the feature made visible: an administrator registers the connector
once on the Plugins page, and every person connects their own account from
Preferences. Two screens because they are two different decisions by two different
people, and putting them together would suggest the administrator's grant is what a
Bot reads with.
On Plugins, a `user-oauth` vendor asks for a client id and secret rather than a
token, and says in as many words that nobody's documents are reachable with what is
typed there. Worth the sentence: "client secret" in a box on an admin page looks
exactly like the access token in the box above it and is a completely different
thing.
The redirect URI is shown beside those fields, served by the server rather than
assembled in the browser, selectable and monospaced. It has to be copied by hand
into somebody else's console and match character for character; getting it wrong
fails at the vendor with a message that never mentions OpenBot, which is an
afternoon lost to a missing slash. A deployment with no public URL says so there
instead of offering fields that cannot lead anywhere.
On Preferences, somebody sees the vendors an administrator has added, whether they
have connected, and when. Only their own — the endpoint answers for whoever is
asking, so no page can render somebody else's by mistake. Connecting is a full page
navigation, not a fetch: the consent screen is the vendor's own and has to be shown
to the person in their own browser. There is deliberately nothing here that could
complete it for them.
A failed callback comes back as `?connected=failed` and says nothing was saved,
which is true — the callback writes nothing on any failure path.
One thing worth knowing for the next route that does this. `validateSearch` returning
`{ connected: undefined }` rather than `{}` makes `search` a required prop on every
`Link to="/settings"` in the app: present-but-undefined is not the same as absent to
the router's types. The key is omitted instead.
Google Drive is now end to end — added, client registered, connected, and called as
the asker — subject to a real client and a real account, which is the part no test
here can stand in for.
---
app/src/lib/plugins/queries.ts | 43 ++++++++
app/src/routes/_authed/admin/plugins.tsx | 102 +++++++++++++++++-
app/src/routes/_authed/settings/index.tsx | 120 +++++++++++++++++++++-
server/src/plugins/routes.ts | 12 +++
4 files changed, 273 insertions(+), 4 deletions(-)
diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts
index e2a64e21..ce8c6450 100644
--- a/app/src/lib/plugins/queries.ts
+++ b/app/src/lib/plugins/queries.ts
@@ -64,6 +64,14 @@ export type PluginsPage = {
catalogue: CatalogueItem[];
servers: PluginServer[];
skills: PluginSkill[];
+ /**
+ * The redirect URI to register with a `user-oauth` vendor, exactly as this deployment will send it.
+ *
+ * From the server rather than assembled here, because it has to match what was registered
+ * character for character. Null when the deployment has no public URL and so cannot complete a
+ * consent flow at all.
+ */
+ redirectUri: string | null;
};
/** What one Bot holds, which is all the runtime needs to offer it. */
@@ -86,8 +94,43 @@ export const pluginKeys = {
all: ["plugins"] as const,
page: () => ["plugins", "page"] as const,
forAgent: (agentId: string) => ["plugins", "for-agent", agentId] as const,
+ connections: () => ["plugins", "connections"] as const,
+};
+
+/** One account this person has connected, from their own point of view. */
+export type PluginConnection = {
+ serverId: string;
+ /** What the vendor actually granted, which is not always what was asked for. */
+ scope: string;
+ connectedAt: string;
+};
+
+export type PluginConnections = {
+ connections: PluginConnection[];
+ redirectUri: string | null;
};
+/**
+ * The signed-in person's own connections.
+ *
+ * There is no version of this scoped to anybody else: the endpoint answers for whoever is asking,
+ * so a page cannot accidentally render somebody else's.
+ */
+export function connectionsQueryOptions() {
+ return queryOptions({
+ queryKey: pluginKeys.connections(),
+ queryFn: async (): Promise => {
+ const response = await fetch("/api/plugins/connections", {
+ credentials: "include",
+ });
+ if (!response.ok) {
+ throw new Error("Your connected accounts could not be loaded.");
+ }
+ return response.json();
+ },
+ });
+}
+
export function pluginsPageQueryOptions() {
return queryOptions({
queryKey: pluginKeys.page(),
diff --git a/app/src/routes/_authed/admin/plugins.tsx b/app/src/routes/_authed/admin/plugins.tsx
index ec1e55d2..bdcc7e66 100644
--- a/app/src/routes/_authed/admin/plugins.tsx
+++ b/app/src/routes/_authed/admin/plugins.tsx
@@ -146,12 +146,26 @@ function PluginsPage() {
server.id))}
items={data.catalogue}
- onAdd={(key, instanceHost, token) =>
+ onAdd={(key, instanceHost, token, oauthClient) =>
mutate.mutate(async () => {
const credentialId = await storeToken(key, token);
- return post("/servers", { key, instanceHost, credentialId });
+ const server = await post("/servers", {
+ key,
+ instanceHost,
+ credentialId,
+ });
+ /*
+ * The client is registered after the server exists, because it is recorded against
+ * that row. Two calls rather than one field on the first, matching the server: a
+ * client is rotated without the server being re-added.
+ */
+ if (oauthClient) {
+ await post(`/servers/${key}/oauth-client`, oauthClient);
+ }
+ return server;
})
}
+ redirectUri={data.redirectUri}
onAddCustom={(input) =>
mutate.mutate(async () => {
const credentialId = await storeToken(input.id, input.token);
@@ -224,6 +238,7 @@ function Catalogue({
added,
onAdd,
onAddCustom,
+ redirectUri,
}: {
items: {
key: string;
@@ -241,7 +256,14 @@ function Catalogue({
perInstance: boolean;
}[];
added: Set;
- onAdd: (key: string, instanceHost?: string, token?: string) => void;
+ onAdd: (
+ key: string,
+ instanceHost?: string,
+ token?: string,
+ oauthClient?: { clientId: string; clientSecret: string },
+ ) => void;
+ /** What to register with a user-oauth vendor. Null when this deployment has no public URL. */
+ redirectUri: string | null;
onAddCustom: (input: {
id: string;
title: string;
@@ -251,6 +273,9 @@ function Catalogue({
}) {
const [instanceHost, setInstanceHost] = useState>({});
const [token, setToken] = useState>({});
+ const [client, setClient] = useState<
+ Record
+ >({});
const [addingCustom, setAddingCustom] = useState(false);
const [custom, setCustom] = useState({
id: "",
@@ -285,6 +310,11 @@ function Catalogue({
item.key,
instanceHost[item.key] || undefined,
token[item.key] || undefined,
+ item.auth === "user-oauth" &&
+ client[item.key]?.clientId &&
+ client[item.key]?.clientSecret
+ ? client[item.key]
+ : undefined,
)
}
size="sm"
@@ -308,6 +338,72 @@ function Catalogue({
value={instanceHost[item.key] ?? ""}
/>
) : null}
+ {item.auth === "user-oauth" ? (
+ /*
+ * A client, not a token. Nobody's documents are reachable with what is typed here: it
+ * identifies this deployment to the vendor, and each person then consents for
+ * themselves in their own settings. Worth saying on the screen, because "client
+ * secret" in a box on an admin page looks exactly like the credential above it and is
+ * a very different thing.
+ */
+
+
+ {item.title} answers as whoever is asking. Register this
+ deployment's OAuth client here; everybody then connects their
+ own account from Preferences.
+
+ Add this to the client's authorised redirect URIs, exactly
+ as written:
+
+ {/*
+ * Selectable and monospaced, because it is copied by hand into somebody else's
+ * console and a single wrong character fails at the vendor with a message that
+ * does not mention us.
+ */}
+
+ {redirectUri}
+
+
+ ) : (
+
+ This deployment has no public URL, so nobody can complete a
+ consent flow. Set OPENBOT_PUBLIC_URL and restart.
+
+ )}
+
+ ) : null}
{item.auth === "deployment-bearer" ? (
/* Mask tokens before they are stored in the credential vault. */
): { connected?: string } =>
+ // The key is omitted rather than set to undefined. Present-but-undefined makes `search` a
+ // required prop on every `Link to="/settings"` in the app, which is a lot of ripple for a
+ // parameter only the OAuth callback ever sets.
+ typeof search.connected === "string" ? { connected: search.connected } : {},
});
function RouteComponent() {
@@ -50,6 +68,106 @@ function RouteComponent() {
+
);
}
+
+/**
+ * The accounts this person has connected, and the ones they could.
+ *
+ * On this page rather than an admin one, which is the shape of the whole feature: an administrator
+ * registers a connector once, and then each person grants access to their own documents. Nobody
+ * sees anybody else's connections here, because there is no version of this list that is not
+ * "yours".
+ */
+function ConnectedAccounts() {
+ const { connected } = useSearch({ from: "/_authed/settings/" });
+ const plugins = useQuery(pluginsPageQueryOptions());
+ const connections = useQuery(connectionsQueryOptions());
+
+ const connect = useMutation({
+ mutationFn: async (serverId: string) => {
+ const response = await fetch(`/api/plugins/servers/${serverId}/connect`, {
+ method: "POST",
+ credentials: "include",
+ });
+ const body = await response.json();
+ if (!response.ok) {
+ throw new Error(body?.error ?? "This could not be connected.");
+ }
+ /*
+ * A full page navigation, not a fetch. The consent screen is the vendor's own and has to be
+ * shown to the person in their own browser — there is nothing here that could complete it on
+ * their behalf, which is the point.
+ */
+ window.location.href = body.authorizationUrl;
+ },
+ });
+
+ /** Only the vendors reached as an individual. Everything else is the deployment's own credential. */
+ const connectable = (plugins.data?.catalogue ?? []).filter(
+ (item) =>
+ item.auth === "user-oauth" &&
+ plugins.data?.servers.some((server) => server.id === item.key),
+ );
+
+ const held = new Map(
+ (connections.data?.connections ?? []).map((row) => [row.serverId, row]),
+ );
+
+ return (
+
+ {connected === "failed" ? (
+
+ That account could not be connected. Nothing was saved — try again.
+
+ ) : null}
+ {connect.error ? (
+
+ {connect.error.message}
+
+ ) : null}
+ {plugins.isPending || connections.isPending ? (
+ Loading…
+ ) : connectable.length === 0 ? (
+
+ Nothing here yet. An administrator adds these on the Plugins page, and
+ they appear for you to connect.
+
+ ) : (
+
+ {connectable.map((item) => {
+ const connection = held.get(item.key);
+ return (
+
+
+ {item.title}
+
+ {connection
+ ? `Connected ${new Date(connection.connectedAt).toLocaleDateString()}.`
+ : item.summary}
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts
index d2f07cb1..a5346ab1 100644
--- a/server/src/plugins/routes.ts
+++ b/server/src/plugins/routes.ts
@@ -99,6 +99,18 @@ export function createPluginRoutes(
servers: await store.listServers(),
// Scoped: the deployment's skills plus this person's own. An administrator sees them all.
skills: await store.listSkills(skillActor(context)),
+ /*
+ * What an administrator has to register with the vendor, character for character.
+ *
+ * Served rather than assembled in the browser, so what is displayed is exactly what the
+ * callback will present. A mismatch here fails at the vendor with a message that does not name
+ * us, which is a bad afternoon for whoever is setting it up.
+ *
+ * Null means this deployment has no public URL, so it cannot complete a consent flow at all.
+ */
+ redirectUri: connect?.publicUrl
+ ? redirectUriFor(connect.publicUrl)
+ : null,
}),
);
From 87ef07c61878c312b5c0609b8e55978e22107ac1 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Thu, 20 Aug 2026 15:49:09 -0300
Subject: [PATCH 08/34] Document the one setting a connected account needs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`OPENBOT_PUBLIC_URL` was added with the connect flow and documented nowhere, which
for a setting whose absence silently means "nobody can connect an account" is the
wrong way round.
Named in all three places this repository documents configuration: `.env.example`,
`docs/configuration.md` and the table there. Says what it is for, that it defaults
to `BETTER_AUTH_URL` so most deployments never set it, and what happens with
neither — the Plugins page reports that a consent flow cannot be completed, rather
than offering fields that lead nowhere.
---
.env.example | 8 ++++++++
docs/configuration.md | 3 +++
2 files changed, 11 insertions(+)
diff --git a/.env.example b/.env.example
index 5ad0165f..7efff543 100644
--- a/.env.example
+++ b/.env.example
@@ -23,6 +23,14 @@ TENANT_PACKAGE_DIR=../examples/fintech
# GOOGLE_OAUTH_CLIENT_SECRET=
# INITIAL_ADMIN_EMAILS=admin@example.com
+# Where this deployment is reached from outside. Only needed for connectors that a person connects
+# their own account to, such as Google Drive: it builds the redirect URI the vendor sends them back
+# to, which has to match what an administrator registered character for character. Defaults to
+# BETTER_AUTH_URL, so most deployments never set it; set it where the API is behind a different
+# public address than the one sign-in uses. Without either, the Plugins page says so and nobody can
+# connect an account.
+# OPENBOT_PUBLIC_URL=https://openbot.example.com
+
# Local development only. This admits every request as one local administrator. Keep it explicit and
# never expose a deployment to the internet in this state; production refuses to start with it.
OPENBOT_DEV_NO_AUTH=true
diff --git a/docs/configuration.md b/docs/configuration.md
index 0657fb24..5db4eb5f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -88,9 +88,12 @@ Two things are worth knowing before pointing a deployment at any gateway. Not ev
| `BETTER_AUTH_URL` | Public API server base URL. Required with Google OAuth. |
| `TRUSTED_ORIGINS` | Comma-separated app origins accepted by the API. |
| `INITIAL_ADMIN_EMAILS` | Comma-separated users seeded as administrators. |
+| `OPENBOT_PUBLIC_URL` | Public address of this deployment. Defaults to `BETTER_AUTH_URL`. |
Google OAuth client id and secret must be configured together. If Google OAuth is configured, `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are also required.
+`OPENBOT_PUBLIC_URL` matters only for a connector each person connects their own account to, such as Google Drive. It builds the redirect URI the vendor sends somebody back to after they consent, which has to match what an administrator registered with that vendor character for character — so it comes from configuration rather than from the incoming request. Most deployments never set it, because `BETTER_AUTH_URL` is already the same public address. Set it where the API is reached at a different one. With neither, the Plugins page says the deployment cannot complete a consent flow, and no account can be connected.
+
## Computer and supervisor
| Variable | Meaning |
From 586ed5804f4695dc8c65922de597e06f1f3965f4 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Thu, 20 Aug 2026 15:58:06 -0300
Subject: [PATCH 09/34] Send somebody back to the app after consenting, not to
the API
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The callback redirected to `/settings`, relative. It lands on the API, so that
resolved against the API's own origin — which locally is port 3001, where no page
is served. The consent would succeed, the grant would be stored correctly, and the
person would arrive on a 404.
Worth being precise about why this got through: nothing was wrong with the flow.
Every test passed because every test was about the flow. The failure is entirely in
the fact that the app and the API are two processes on two ports, which is a fact
about running the thing rather than about any function in it.
`OPENBOT_APP_URL` names where the app is, defaulting to the first `TRUSTED_ORIGINS`
entry — already defined as where the app is served from — and then to the API's own
public URL, which is right for a deployment serving both from one origin. Relative
stays the answer in that last case, which is the only one where the setting can be
absent and the deployment still work.
`settingsUrlFor` so the three outcomes are one function with tests, rather than
string concatenation at two call sites.
Documented alongside `OPENBOT_PUBLIC_URL` in `.env.example` and
`docs/configuration.md`, including the port numbers, since the reason the two
settings are separate is not guessable from their names.
---
.env.example | 6 +++++
docs/configuration.md | 9 ++++++--
server/src/app.ts | 1 +
server/src/config.ts | 19 ++++++++++++++++
server/src/plugins/oauth.ts | 20 +++++++++++++++++
server/src/plugins/routes.ts | 19 ++++++++++++----
server/tests/plugin-oauth.test.ts | 37 +++++++++++++++++++++++++++++++
7 files changed, 105 insertions(+), 6 deletions(-)
diff --git a/.env.example b/.env.example
index 7efff543..76bcc703 100644
--- a/.env.example
+++ b/.env.example
@@ -31,6 +31,12 @@ TENANT_PACKAGE_DIR=../examples/fintech
# connect an account.
# OPENBOT_PUBLIC_URL=https://openbot.example.com
+# Where the browser app is served from. The OAuth callback lands on the API and has to send the
+# person back to a page, and locally those are two different ports: the app is 3010 and the API is
+# 3001. Defaults to the first TRUSTED_ORIGINS entry, then to OPENBOT_PUBLIC_URL, which is right for a
+# deployment serving both from one origin.
+# OPENBOT_APP_URL=http://localhost:3010
+
# Local development only. This admits every request as one local administrator. Keep it explicit and
# never expose a deployment to the internet in this state; production refuses to start with it.
OPENBOT_DEV_NO_AUTH=true
diff --git a/docs/configuration.md b/docs/configuration.md
index 5db4eb5f..29fd75f7 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -88,11 +88,16 @@ Two things are worth knowing before pointing a deployment at any gateway. Not ev
| `BETTER_AUTH_URL` | Public API server base URL. Required with Google OAuth. |
| `TRUSTED_ORIGINS` | Comma-separated app origins accepted by the API. |
| `INITIAL_ADMIN_EMAILS` | Comma-separated users seeded as administrators. |
-| `OPENBOT_PUBLIC_URL` | Public address of this deployment. Defaults to `BETTER_AUTH_URL`. |
+| `OPENBOT_PUBLIC_URL` | Public address of this API. Defaults to `BETTER_AUTH_URL`. |
+| `OPENBOT_APP_URL` | Where the browser app is served. Defaults to the first `TRUSTED_ORIGINS` entry. |
Google OAuth client id and secret must be configured together. If Google OAuth is configured, `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are also required.
-`OPENBOT_PUBLIC_URL` matters only for a connector each person connects their own account to, such as Google Drive. It builds the redirect URI the vendor sends somebody back to after they consent, which has to match what an administrator registered with that vendor character for character — so it comes from configuration rather than from the incoming request. Most deployments never set it, because `BETTER_AUTH_URL` is already the same public address. Set it where the API is reached at a different one. With neither, the Plugins page says the deployment cannot complete a consent flow, and no account can be connected.
+`OPENBOT_PUBLIC_URL` and `OPENBOT_APP_URL` matter only for a connector each person connects their own account to, such as Google Drive.
+
+`OPENBOT_PUBLIC_URL` builds the redirect URI the vendor sends somebody back to after they consent, which has to match what an administrator registered with that vendor character for character — so it comes from configuration rather than from the incoming request. Most deployments never set it, because `BETTER_AUTH_URL` is already the same public address. With neither, the Plugins page says the deployment cannot complete a consent flow, and no account can be connected.
+
+`OPENBOT_APP_URL` is where the callback sends the person afterwards. It is a separate setting because the app and the API are separate addresses: locally the app is Vite on `3010` and the API is `3001`, so a relative redirect would land on the API, which serves no pages. A deployment serving both from one origin can leave it unset.
## Computer and supervisor
diff --git a/server/src/app.ts b/server/src/app.ts
index e6f3774b..31f8d959 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -311,6 +311,7 @@ export function createApp(
createPluginRoutes(pluginStore, requireUser, {
encryptionKey: config.keyEncryptionKey,
publicUrl: config.publicUrl,
+ appUrl: config.appUrl,
}),
);
}
diff --git a/server/src/config.ts b/server/src/config.ts
index 09cc6550..9a089585 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -45,6 +45,19 @@ export type DeploymentConfig = {
* deployment running without authentication — and there is nothing to connect there anyway.
*/
publicUrl: string | undefined;
+ /**
+ * Where the browser app is served from, with no trailing slash.
+ *
+ * Separate from {@link DeploymentConfig.publicUrl} because they are genuinely two addresses: the
+ * app is a Vite process on its own port locally, and the API is another. An OAuth callback lands on
+ * the API and has to send the person back to a page, so a relative redirect would put them on the
+ * API's origin, where no page exists.
+ *
+ * `OPENBOT_APP_URL` when set, otherwise the first `TRUSTED_ORIGINS` entry, which is already defined
+ * as where the app is served from. Falls back to the API's own public URL, which is right for a
+ * deployment serving both from one origin.
+ */
+ appUrl: string | undefined;
tenantPackageDirectory: string;
runtime: RuntimeCapabilities;
/**
@@ -390,6 +403,12 @@ export function loadConfig(
publicUrl: (
optional(environment, "OPENBOT_PUBLIC_URL") ?? auth?.baseUrl
)?.replace(/\/+$/, ""),
+ appUrl: (
+ optional(environment, "OPENBOT_APP_URL") ??
+ commaSeparated(environment, "TRUSTED_ORIGINS")[0] ??
+ optional(environment, "OPENBOT_PUBLIC_URL") ??
+ auth?.baseUrl
+ )?.replace(/\/+$/, ""),
tenantPackageDirectory:
optional(environment, "TENANT_PACKAGE_DIR") ?? "../examples/fintech",
runtime: runtimeCapabilities(environment),
diff --git a/server/src/plugins/oauth.ts b/server/src/plugins/oauth.ts
index ac043320..b9598c23 100644
--- a/server/src/plugins/oauth.ts
+++ b/server/src/plugins/oauth.ts
@@ -58,6 +58,26 @@ export function redirectUriFor(publicUrl: string): string {
return `${publicUrl.replace(/\/+$/, "")}${CALLBACK_PATH}`;
}
+/**
+ * Where the callback sends somebody when it is done, succeeded or failed.
+ *
+ * Absolute, on the app's origin, because the callback lands on the API and those are two different
+ * addresses: locally the app is Vite on one port and this server is another. A relative redirect
+ * resolves against this server, which serves no pages, so the flow would complete correctly — grant
+ * stored, everything right — and drop the person on a 404. Nothing about that reads as a connect
+ * failure, which is what makes it worth naming.
+ *
+ * Relative is still correct for a deployment that serves both from one origin, which is the only
+ * case where `appUrl` is absent and the deployment works.
+ */
+export function settingsUrlFor(
+ appUrl: string | undefined,
+ outcome?: string,
+): string {
+ const base = `${appUrl?.replace(/\/+$/, "") ?? ""}/settings`;
+ return outcome ? `${base}?connected=${outcome}` : base;
+}
+
/** A fresh PKCE verifier: unreserved characters only, comfortably over the 43-character floor. */
export function createVerifier(): string {
return randomBytes(48).toString("base64url");
diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts
index a5346ab1..af59c4b4 100644
--- a/server/src/plugins/routes.ts
+++ b/server/src/plugins/routes.ts
@@ -10,6 +10,7 @@ import {
readConnectState,
redeemAuthorizationCode,
redirectUriFor,
+ settingsUrlFor,
signConnectState,
} from "./oauth";
import {
@@ -46,7 +47,18 @@ export function createPluginRoutes(
* Optional, so a deployment with no public URL configured simply cannot start a connect flow and
* says so, rather than building a redirect URI out of a request header and failing at the vendor.
*/
- connect?: { encryptionKey: string; publicUrl: string | undefined },
+ connect?: {
+ encryptionKey: string;
+ publicUrl: string | undefined;
+ /**
+ * Where the app is, which is not where this API is.
+ *
+ * The callback lands here and has to send the person back to a page. A relative redirect would
+ * put them on this server's origin, which locally is a Vite-less port that serves no pages at
+ * all — so the flow would complete correctly and end on a 404.
+ */
+ appUrl: string | undefined;
+ },
) {
const routes = new Hono<{ Variables: AppVariables }>();
@@ -345,8 +357,7 @@ export function createPluginRoutes(
* one, and spelling out which is which tells anybody probing this endpoint how far they got.
*/
routes.get("/oauth/callback", async (context) => {
- const settings = "/settings";
- const failed = `${settings}?connected=failed`;
+ const failed = settingsUrlFor(connect?.appUrl, "failed");
if (!connect?.publicUrl) return context.redirect(failed);
const code = context.req.query("code");
@@ -379,7 +390,7 @@ export function createPluginRoutes(
scope: grant.scope,
});
- return context.redirect(`${settings}?connected=${state.serverId}`);
+ return context.redirect(settingsUrlFor(connect.appUrl, state.serverId));
});
/**
diff --git a/server/tests/plugin-oauth.test.ts b/server/tests/plugin-oauth.test.ts
index bf01d168..0c3f1923 100644
--- a/server/tests/plugin-oauth.test.ts
+++ b/server/tests/plugin-oauth.test.ts
@@ -6,6 +6,7 @@ import {
createVerifier,
readConnectState,
redirectUriFor,
+ settingsUrlFor,
signConnectState,
} from "../src/plugins/oauth";
@@ -169,3 +170,39 @@ describe("the address the vendor sends them back to", () => {
);
});
});
+
+describe("where the callback sends somebody afterwards", () => {
+ /*
+ * The bug this exists to prevent, found by trying to run the thing rather than by reading it.
+ *
+ * The app and the API are two processes on two ports: Vite on 3010, this server on 3001. The
+ * callback lands on the API, so `redirect("/settings")` resolved against the API's origin and ended
+ * on a 404 — after the consent had succeeded and the grant was already stored. Nothing about that
+ * looks like a failure of the connect flow, which is why it needs a test and not a comment.
+ */
+ test("is the app's origin, not the API's", () => {
+ expect(settingsUrlFor("http://localhost:3010")).toBe(
+ "http://localhost:3010/settings",
+ );
+ });
+
+ test("says that it failed, without saying which way", () => {
+ // One outcome for a forged state and for an expired one. Telling them apart tells anybody
+ // probing the endpoint how far they got.
+ expect(settingsUrlFor("http://localhost:3010", "failed")).toBe(
+ "http://localhost:3010/settings?connected=failed",
+ );
+ });
+
+ test("names the server it connected, so the page can confirm which", () => {
+ expect(settingsUrlFor("http://localhost:3010", "google-drive")).toBe(
+ "http://localhost:3010/settings?connected=google-drive",
+ );
+ });
+
+ test("still points somewhere when no app URL is configured", () => {
+ // Relative is wrong on a split-port deployment and right on a single-origin one, which is the
+ // only case where `appUrl` can be absent and the deployment still works.
+ expect(settingsUrlFor(undefined)).toBe("/settings");
+ });
+});
From 02a4531c38fbd3a373136d25a3c575696dbf8005 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Fri, 21 Aug 2026 11:17:24 -0300
Subject: [PATCH 10/34] Give every plugin its own page, and two lists instead
of three tabs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plugins was three tabs: a catalogue of what could be added, a second tab for what
had been, and skills. Answering one question about one vendor — is Drive available,
and what can it do — meant visiting two of them, and the third was a different kind
of thing altogether.
Now two lists. Connected says what is added and where it stands; Explore plugins
says what else there is. Each row goes to that vendor's own page, because what a
connector needs configured is not the same from one vendor to the next: a token for
one, an OAuth client and a redirect URI for another, an instance hostname for a
third, and then a grant per tool per Bot. The old screen tried to hold all of that
in a list and grew a column per Bot, which is how a grant goes unread.
The detail page switches on the `auth` discriminator the knowledge lane added, which
is the first thing to make real use of it. A bearer vendor gets a token row; a
user-oauth vendor gets the client, the redirect URI to copy, and a read-only row for
your own connection that points at Preferences — because connecting is yours and not
an administrator's act. ServiceNow gets both, being per-instance as well.
Skills moved to `/admin/skills`. A skill is not a connector: it adds no capability
at all, only asks a Bot to use what it already holds. Sitting in a list of vendors
made it look like a third thing a Bot could reach.
Grants moved into `ItemFooter`, where the layout skill puts a set. In `ItemActions`
a chip per Bot fights the tool name for horizontal space, which is what started the
sideways scrolling.
The add-by-URL flow is gone from the screen. `POST /api/plugins/servers/custom` and
`addCustomServer` stay: they are governed and tested, and removing an endpoint is a
different change from restyling a screen. A custom server already in the database
still lists under Connected, marked by its provenance.
Two repairs while in here. `addServerWithToken` awaited nothing, so a failed OAuth
client registration went to an unhandled rejection instead of the error banner; it
awaits now and takes an `after` step. And the layout skill pointed twice at
`admin/connectors.tsx` as its reference screen, which Slice 0 deleted — repointed at
the two new files. The sidebar's "seven things" comment was also stale before this
change and now says ten.
No tests: this is UI, and the repo's frontend has almost none. Driven by hand
instead — both lists, all three auth kinds, and add/refresh/remove end to end. The
error path is real: adding Atlassian with no token lists no tools and shows the
vendor's own refusal on the row, which is what that state should look like.
---
.claude/skills/openbot-screen-layout/SKILL.md | 7 +-
app/src/components/admin/admin-sidebar.tsx | 12 +-
app/src/routeTree.gen.ts | 76 +-
app/src/routes/_authed/admin/index.tsx | 10 +-
app/src/routes/_authed/admin/plugins.tsx | 865 ------------------
app/src/routes/_authed/admin/plugins/$key.tsx | 570 ++++++++++++
.../routes/_authed/admin/plugins/index.tsx | 237 +++++
app/src/routes/_authed/admin/skills.tsx | 267 ++++++
8 files changed, 1155 insertions(+), 889 deletions(-)
delete mode 100644 app/src/routes/_authed/admin/plugins.tsx
create mode 100644 app/src/routes/_authed/admin/plugins/$key.tsx
create mode 100644 app/src/routes/_authed/admin/plugins/index.tsx
create mode 100644 app/src/routes/_authed/admin/skills.tsx
diff --git a/.claude/skills/openbot-screen-layout/SKILL.md b/.claude/skills/openbot-screen-layout/SKILL.md
index a8e5f96c..04ebc41c 100644
--- a/.claude/skills/openbot-screen-layout/SKILL.md
+++ b/.claude/skills/openbot-screen-layout/SKILL.md
@@ -71,8 +71,9 @@ here yet" is a fact, and the section heading already said what the section is fo
4. Give each section one `PageRows` card. Rows go inside it as `Item size="sm"`, with ``
between them and none after the last. `PageRows` is a card with dividers, not a stack of cards —
gaps between rows are the wrong shape.
-5. Read `admin/connectors.tsx` for the whole pattern end to end, and
- `admin/components/$name.tsx` for a screen with two sections and mixed row kinds.
+5. Read `admin/plugins/index.tsx` for the whole pattern end to end, and
+ `admin/plugins/$key.tsx` or `admin/components/$name.tsx` for a screen with several sections and
+ mixed row kinds.
### Procedure 2: Compose a row
@@ -205,7 +206,7 @@ to sit a centred element visibly off centre or clip a card's corners against its
- **A row's summary does not change after its dialog edits something**: the summary was captured into
state instead of computed from the query. Derive it on every render.
- **Two screens that should match do not**: one of them drew its own container. Diff the two against
- `admin/connectors.tsx` and delete whichever hand-drawn wrapper is not `PageRows`.
+ `admin/plugins/index.tsx` and delete whichever hand-drawn wrapper is not `PageRows`.
- **The layout genuinely cannot express the screen**: stop and say so rather than bending it
silently. A deviation with a stated reason and a comment is fine; an undocumented fifth way to draw
a card is what this skill exists to prevent.
diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx
index fe9a16a2..47aa5b46 100644
--- a/app/src/components/admin/admin-sidebar.tsx
+++ b/app/src/components/admin/admin-sidebar.tsx
@@ -3,6 +3,7 @@ import {
IconBuildingBank,
IconCode,
IconDeviceDesktop,
+ IconFileText,
IconKey,
IconLayoutGrid,
IconListDetails,
@@ -28,11 +29,11 @@ const appLinkOptions = { to: "/" } satisfies LinkOptions;
const adminLinkOptions = { to: "/admin" } satisfies LinkOptions;
/**
- * The same three groups, in the same order, as the admin index.
+ * The same four groups, in the same order, as the admin index.
*
- * A rail that lists seven things flat asks somebody to know which of them is the one they want. The
+ * A rail that lists ten things flat asks somebody to know which of them is the one they want. The
* grouping is the only navigation help this screen offers, so it has to agree with the page it
- * navigates to — two different orderings of the same seven links is worse than either ordering.
+ * navigates to — two different orderings of the same ten links is worse than either ordering.
*/
const GROUPS: {
label: string;
@@ -70,6 +71,11 @@ const GROUPS: {
icon: IconPuzzle,
linkOptions: { to: "/admin/plugins" },
},
+ {
+ title: "Skills",
+ icon: IconFileText,
+ linkOptions: { to: "/admin/skills" },
+ },
{
title: "UI Components",
icon: IconLayoutGrid,
diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts
index 1d97db7a..6e7020ce 100644
--- a/app/src/routeTree.gen.ts
+++ b/app/src/routeTree.gen.ts
@@ -25,13 +25,15 @@ import { Route as AuthedAdminCredentialsRouteImport } from './routes/_authed/adm
import { Route as AuthedAdminIdentityProvidersRouteImport } from './routes/_authed/admin/identity-providers'
import { Route as AuthedAdminPeopleRouteImport } from './routes/_authed/admin/people'
import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admin/playground'
-import { Route as AuthedAdminPluginsRouteImport } from './routes/_authed/admin/plugins'
+import { Route as AuthedAdminSkillsRouteImport } from './routes/_authed/admin/skills'
import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settings/index'
import { Route as AuthedAppAgentsIndexRouteImport } from './routes/_authed/_app/agents/index'
import { Route as AuthedAppChannelChannelIdRouteImport } from './routes/_authed/_app/channel/$channelId'
import { Route as AuthedAppChannelNewRouteImport } from './routes/_authed/_app/channel/new'
import { Route as AuthedAdminComponentsIndexRouteImport } from './routes/_authed/admin/components/index'
import { Route as AuthedAdminComponentsNameRouteImport } from './routes/_authed/admin/components/$name'
+import { Route as AuthedAdminPluginsIndexRouteImport } from './routes/_authed/admin/plugins/index'
+import { Route as AuthedAdminPluginsKeyRouteImport } from './routes/_authed/admin/plugins/$key'
import { Route as AuthedSettingsComponentsGalleryIndexRouteImport } from './routes/_authed/settings/components-gallery/index'
import { Route as AuthedSettingsComponentsGalleryNameRouteImport } from './routes/_authed/settings/components-gallery/$name'
@@ -114,9 +116,9 @@ const AuthedAdminPlaygroundRoute = AuthedAdminPlaygroundRouteImport.update({
path: '/playground',
getParentRoute: () => AuthedAdminRouteRoute,
} as any)
-const AuthedAdminPluginsRoute = AuthedAdminPluginsRouteImport.update({
- id: '/plugins',
- path: '/plugins',
+const AuthedAdminSkillsRoute = AuthedAdminSkillsRouteImport.update({
+ id: '/skills',
+ path: '/skills',
getParentRoute: () => AuthedAdminRouteRoute,
} as any)
const AuthedSettingsIndexRoute = AuthedSettingsIndexRouteImport.update({
@@ -152,6 +154,16 @@ const AuthedAdminComponentsNameRoute =
path: '/components/$name',
getParentRoute: () => AuthedAdminRouteRoute,
} as any)
+const AuthedAdminPluginsIndexRoute = AuthedAdminPluginsIndexRouteImport.update({
+ id: '/plugins/',
+ path: '/plugins/',
+ getParentRoute: () => AuthedAdminRouteRoute,
+} as any)
+const AuthedAdminPluginsKeyRoute = AuthedAdminPluginsKeyRouteImport.update({
+ id: '/plugins/$key',
+ path: '/plugins/$key',
+ getParentRoute: () => AuthedAdminRouteRoute,
+} as any)
const AuthedSettingsComponentsGalleryIndexRoute =
AuthedSettingsComponentsGalleryIndexRouteImport.update({
id: '/components-gallery/',
@@ -179,15 +191,17 @@ export interface FileRoutesByFullPath {
'/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute
'/admin/people': typeof AuthedAdminPeopleRoute
'/admin/playground': typeof AuthedAdminPlaygroundRoute
- '/admin/plugins': typeof AuthedAdminPluginsRoute
+ '/admin/skills': typeof AuthedAdminSkillsRoute
'/admin/': typeof AuthedAdminIndexRoute
'/settings/': typeof AuthedSettingsIndexRoute
'/channel/$channelId': typeof AuthedAppChannelChannelIdRoute
'/channel/new': typeof AuthedAppChannelNewRoute
'/admin/components/$name': typeof AuthedAdminComponentsNameRoute
+ '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute
'/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute
'/agents/': typeof AuthedAppAgentsIndexRoute
'/admin/components/': typeof AuthedAdminComponentsIndexRoute
+ '/admin/plugins/': typeof AuthedAdminPluginsIndexRoute
'/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute
}
export interface FileRoutesByTo {
@@ -202,15 +216,17 @@ export interface FileRoutesByTo {
'/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute
'/admin/people': typeof AuthedAdminPeopleRoute
'/admin/playground': typeof AuthedAdminPlaygroundRoute
- '/admin/plugins': typeof AuthedAdminPluginsRoute
+ '/admin/skills': typeof AuthedAdminSkillsRoute
'/admin': typeof AuthedAdminIndexRoute
'/settings': typeof AuthedSettingsIndexRoute
'/channel/$channelId': typeof AuthedAppChannelChannelIdRoute
'/channel/new': typeof AuthedAppChannelNewRoute
'/admin/components/$name': typeof AuthedAdminComponentsNameRoute
+ '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute
'/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute
'/agents': typeof AuthedAppAgentsIndexRoute
'/admin/components': typeof AuthedAdminComponentsIndexRoute
+ '/admin/plugins': typeof AuthedAdminPluginsIndexRoute
'/settings/components-gallery': typeof AuthedSettingsComponentsGalleryIndexRoute
}
export interface FileRoutesById {
@@ -229,16 +245,18 @@ export interface FileRoutesById {
'/_authed/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute
'/_authed/admin/people': typeof AuthedAdminPeopleRoute
'/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute
- '/_authed/admin/plugins': typeof AuthedAdminPluginsRoute
+ '/_authed/admin/skills': typeof AuthedAdminSkillsRoute
'/_authed/_app/': typeof AuthedAppIndexRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/settings/': typeof AuthedSettingsIndexRoute
'/_authed/_app/channel/$channelId': typeof AuthedAppChannelChannelIdRoute
'/_authed/_app/channel/new': typeof AuthedAppChannelNewRoute
'/_authed/admin/components/$name': typeof AuthedAdminComponentsNameRoute
+ '/_authed/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute
'/_authed/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute
'/_authed/_app/agents/': typeof AuthedAppAgentsIndexRoute
'/_authed/admin/components/': typeof AuthedAdminComponentsIndexRoute
+ '/_authed/admin/plugins/': typeof AuthedAdminPluginsIndexRoute
'/_authed/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute
}
export interface FileRouteTypes {
@@ -257,15 +275,17 @@ export interface FileRouteTypes {
| '/admin/identity-providers'
| '/admin/people'
| '/admin/playground'
- | '/admin/plugins'
+ | '/admin/skills'
| '/admin/'
| '/settings/'
| '/channel/$channelId'
| '/channel/new'
| '/admin/components/$name'
+ | '/admin/plugins/$key'
| '/settings/components-gallery/$name'
| '/agents/'
| '/admin/components/'
+ | '/admin/plugins/'
| '/settings/components-gallery/'
fileRoutesByTo: FileRoutesByTo
to:
@@ -280,15 +300,17 @@ export interface FileRouteTypes {
| '/admin/identity-providers'
| '/admin/people'
| '/admin/playground'
- | '/admin/plugins'
+ | '/admin/skills'
| '/admin'
| '/settings'
| '/channel/$channelId'
| '/channel/new'
| '/admin/components/$name'
+ | '/admin/plugins/$key'
| '/settings/components-gallery/$name'
| '/agents'
| '/admin/components'
+ | '/admin/plugins'
| '/settings/components-gallery'
id:
| '__root__'
@@ -306,16 +328,18 @@ export interface FileRouteTypes {
| '/_authed/admin/identity-providers'
| '/_authed/admin/people'
| '/_authed/admin/playground'
- | '/_authed/admin/plugins'
+ | '/_authed/admin/skills'
| '/_authed/_app/'
| '/_authed/admin/'
| '/_authed/settings/'
| '/_authed/_app/channel/$channelId'
| '/_authed/_app/channel/new'
| '/_authed/admin/components/$name'
+ | '/_authed/admin/plugins/$key'
| '/_authed/settings/components-gallery/$name'
| '/_authed/_app/agents/'
| '/_authed/admin/components/'
+ | '/_authed/admin/plugins/'
| '/_authed/settings/components-gallery/'
fileRoutesById: FileRoutesById
}
@@ -438,11 +462,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminPlaygroundRouteImport
parentRoute: typeof AuthedAdminRouteRoute
}
- '/_authed/admin/plugins': {
- id: '/_authed/admin/plugins'
- path: '/plugins'
- fullPath: '/admin/plugins'
- preLoaderRoute: typeof AuthedAdminPluginsRouteImport
+ '/_authed/admin/skills': {
+ id: '/_authed/admin/skills'
+ path: '/skills'
+ fullPath: '/admin/skills'
+ preLoaderRoute: typeof AuthedAdminSkillsRouteImport
parentRoute: typeof AuthedAdminRouteRoute
}
'/_authed/settings/': {
@@ -487,6 +511,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminComponentsNameRouteImport
parentRoute: typeof AuthedAdminRouteRoute
}
+ '/_authed/admin/plugins/': {
+ id: '/_authed/admin/plugins/'
+ path: '/plugins'
+ fullPath: '/admin/plugins/'
+ preLoaderRoute: typeof AuthedAdminPluginsIndexRouteImport
+ parentRoute: typeof AuthedAdminRouteRoute
+ }
+ '/_authed/admin/plugins/$key': {
+ id: '/_authed/admin/plugins/$key'
+ path: '/plugins/$key'
+ fullPath: '/admin/plugins/$key'
+ preLoaderRoute: typeof AuthedAdminPluginsKeyRouteImport
+ parentRoute: typeof AuthedAdminRouteRoute
+ }
'/_authed/settings/components-gallery/': {
id: '/_authed/settings/components-gallery/'
path: '/components-gallery'
@@ -512,10 +550,12 @@ interface AuthedAdminRouteRouteChildren {
AuthedAdminIdentityProvidersRoute: typeof AuthedAdminIdentityProvidersRoute
AuthedAdminPeopleRoute: typeof AuthedAdminPeopleRoute
AuthedAdminPlaygroundRoute: typeof AuthedAdminPlaygroundRoute
- AuthedAdminPluginsRoute: typeof AuthedAdminPluginsRoute
+ AuthedAdminSkillsRoute: typeof AuthedAdminSkillsRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminComponentsNameRoute: typeof AuthedAdminComponentsNameRoute
+ AuthedAdminPluginsKeyRoute: typeof AuthedAdminPluginsKeyRoute
AuthedAdminComponentsIndexRoute: typeof AuthedAdminComponentsIndexRoute
+ AuthedAdminPluginsIndexRoute: typeof AuthedAdminPluginsIndexRoute
}
const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = {
@@ -526,10 +566,12 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = {
AuthedAdminIdentityProvidersRoute: AuthedAdminIdentityProvidersRoute,
AuthedAdminPeopleRoute: AuthedAdminPeopleRoute,
AuthedAdminPlaygroundRoute: AuthedAdminPlaygroundRoute,
- AuthedAdminPluginsRoute: AuthedAdminPluginsRoute,
+ AuthedAdminSkillsRoute: AuthedAdminSkillsRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminComponentsNameRoute: AuthedAdminComponentsNameRoute,
+ AuthedAdminPluginsKeyRoute: AuthedAdminPluginsKeyRoute,
AuthedAdminComponentsIndexRoute: AuthedAdminComponentsIndexRoute,
+ AuthedAdminPluginsIndexRoute: AuthedAdminPluginsIndexRoute,
}
const AuthedAdminRouteRouteWithChildren =
diff --git a/app/src/routes/_authed/admin/index.tsx b/app/src/routes/_authed/admin/index.tsx
index 7cd72f78..88839f17 100644
--- a/app/src/routes/_authed/admin/index.tsx
+++ b/app/src/routes/_authed/admin/index.tsx
@@ -3,6 +3,7 @@ import {
IconChevronRight,
IconCode,
IconDeviceDesktop,
+ IconFileText,
IconKey,
IconLayoutGrid,
IconListDetails,
@@ -81,10 +82,17 @@ const SECTIONS: {
items: [
{
title: "Plugins",
- description: "Skills and tools installed for the whole workspace.",
+ description:
+ "The services this deployment can reach, and which Bots may.",
icon: IconPuzzle,
linkOptions: { to: "/admin/plugins" },
},
+ {
+ title: "Skills",
+ description: "Named instructions anybody can invoke with a slash.",
+ icon: IconFileText,
+ linkOptions: { to: "/admin/skills" },
+ },
{
title: "UI Components",
description: "Custom pieces a Bot can draw in a conversation.",
diff --git a/app/src/routes/_authed/admin/plugins.tsx b/app/src/routes/_authed/admin/plugins.tsx
deleted file mode 100644
index 87d76575..00000000
--- a/app/src/routes/_authed/admin/plugins.tsx
+++ /dev/null
@@ -1,865 +0,0 @@
-import { IconPlus } from "@tabler/icons-react";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { createFileRoute } from "@tanstack/react-router";
-import * as React from "react";
-import { useState } from "react";
-import { PageSection, PageShell } from "@/components/layout/page-shell";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogBody,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
-import { Input } from "@/components/ui/input";
-import { Separator } from "@/components/ui/separator";
-import { Textarea } from "@/components/ui/textarea";
-import { useBotNames } from "@/lib/agents/bot-names";
-import { agentListQueryOptions } from "@/lib/agents/queries";
-import { storeMcpToken } from "@/lib/credentials/mutations";
-import {
- addCuratedServerMutationOptions,
- addCustomServerMutationOptions,
- refreshPluginServerMutationOptions,
- registerOAuthClientMutationOptions,
- removePluginServerMutationOptions,
- removeSkillMutationOptions,
- saveSkillMutationOptions,
- setPluginGrantMutationOptions,
-} from "@/lib/plugins/mutations";
-import {
- type PluginServer,
- type PluginSkill,
- pluginsPageQueryOptions,
-} from "@/lib/plugins/queries";
-
-/**
- * Account-wide plugin and skill installation, with separate per-Bot grants.
- */
-export const Route = createFileRoute("/_authed/admin/plugins")({
- component: PluginsPage,
-});
-
-function PluginsPage() {
- const queryClient = useQueryClient();
- const { data, isPending, isError } = useQuery(pluginsPageQueryOptions());
- const { data: agents } = useQuery(agentListQueryOptions());
- const nameFor = useBotNames();
- const [tab, setTab] = useState<"catalogue" | "yours" | "skills">("catalogue");
- const [error, setError] = useState(null);
-
- /*
- * Every write on this page reports into the same banner, so they share one failure handler rather
- * than each growing its own. `refresh` stays because two of them are chains that end in a server
- * record the catalogue reads back.
- */
- const report = { onError: (thrown: Error) => setError(thrown.message) };
- const setGrant = useMutation({
- ...setPluginGrantMutationOptions(queryClient),
- ...report,
- });
- const addCurated = useMutation({
- ...addCuratedServerMutationOptions(queryClient),
- ...report,
- });
- const addCustom = useMutation({
- ...addCustomServerMutationOptions(queryClient),
- ...report,
- });
- const refreshServer = useMutation({
- ...refreshPluginServerMutationOptions(queryClient),
- ...report,
- });
- const removeServer = useMutation({
- ...removePluginServerMutationOptions(queryClient),
- ...report,
- });
- const registerClient = useMutation({
- ...registerOAuthClientMutationOptions(queryClient),
- ...report,
- });
- const saveSkill = useMutation({
- ...saveSkillMutationOptions(queryClient),
- ...report,
- });
- const removeSkill = useMutation({
- ...removeSkillMutationOptions(queryClient),
- ...report,
- });
-
- /**
- * Adding a server is two writes, and three for a vendor reached as the person asking: the token
- * becomes a credential, the record refers to it, and an OAuth client is registered against the
- * record that now exists.
- *
- * Chained here rather than in a factory because only this page knows what was typed. `after` runs
- * last because the client is recorded against the server row, so it cannot be sent until that row
- * is there — and it awaits, so a failure to register lands in the same banner instead of vanishing.
- */
- const addServerWithToken = async (
- run: (credentialId?: string) => Promise,
- serverId: string,
- token?: string,
- after?: () => Promise,
- ) => {
- setError(null);
- try {
- await run(await storeMcpToken(serverId, token));
- if (after) await after();
- } catch (thrown) {
- setError((thrown as Error).message);
- }
- };
-
- const bots = (agents ?? []).map((agent: { id: string }) => ({
- id: agent.id,
- name: nameFor(agent.id),
- }));
-
- return (
-
-
-
-
-
- );
-}
-
-function Catalogue({
- items,
- added,
- onAdd,
- onAddCustom,
- redirectUri,
-}: {
- items: {
- key: string;
- title: string;
- vendor: string;
- summary: string;
- docsUrl: string;
- /**
- * Whose credential reaches this server.
- *
- * `deployment-bearer` is the only one an administrator can satisfy by typing a token here.
- * `user-oauth` is reached as whoever is asking, so there is no token for this page to collect.
- */
- auth: "none" | "deployment-bearer" | "user-oauth";
- perInstance: boolean;
- }[];
- added: Set;
- onAdd: (
- key: string,
- instanceHost?: string,
- token?: string,
- oauthClient?: { clientId: string; clientSecret: string },
- ) => void;
- /** What to register with a user-oauth vendor. Null when this deployment has no public URL. */
- redirectUri: string | null;
- onAddCustom: (input: {
- id: string;
- title: string;
- url: string;
- token?: string;
- }) => void;
-}) {
- const [instanceHost, setInstanceHost] = useState>({});
- const [token, setToken] = useState>({});
- const [client, setClient] = useState<
- Record
- >({});
- const [addingCustom, setAddingCustom] = useState(false);
- const [custom, setCustom] = useState({
- id: "",
- title: "",
- url: "",
- token: "",
- });
-
- return (
-
- {/*
- * ONE COLUMN, like every other list in the app. Two columns meant the eye had to choose a
- * side and then come back for the other, on a page whose whole job is "what can this
- * deployment reach" — a question answered by reading down a list once.
- */}
-
- {items.map((item) => (
-
-
-
-
{item.title}
-
{item.summary}
-
-
-
- {item.perInstance ? (
-
- setInstanceHost((current) => ({
- ...current,
- [item.key]: event.target.value,
- }))
- }
- placeholder="https://your-instance.service-now.com"
- value={instanceHost[item.key] ?? ""}
- />
- ) : null}
- {item.auth === "user-oauth" ? (
- /*
- * A client, not a token. Nobody's documents are reachable with what is typed here: it
- * identifies this deployment to the vendor, and each person then consents for
- * themselves in their own settings. Worth saying on the screen, because "client
- * secret" in a box on an admin page looks exactly like the credential above it and is
- * a very different thing.
- */
-
-
- {item.title} answers as whoever is asking. Register this
- deployment's OAuth client here; everybody then connects their
- own account from Preferences.
-
- Add this to the client's authorised redirect URIs, exactly
- as written:
-
- {/*
- * Selectable and monospaced, because it is copied by hand into somebody else's
- * console and a single wrong character fails at the vendor with a message that
- * does not mention us.
- */}
-
- {redirectUri}
-
-
- ) : (
-
- This deployment has no public URL, so nobody can complete a
- consent flow. Set OPENBOT_PUBLIC_URL and restart.
-
- )}
-
- ) : null}
- {item.auth === "deployment-bearer" ? (
- /* Mask tokens before they are stored in the credential vault. */
-
- setToken((current) => ({
- ...current,
- [item.key]: event.target.value,
- }))
- }
- placeholder="Access token for this server"
- type="password"
- value={token[item.key] ?? ""}
- />
- ) : null}
-
- Vendor documentation
-
-
- No servers added yet. The Catalogue tab is where they come from.
-
- );
- }
-
- return (
-
- {/*
- * The two words beside each tool decide how it is judged, and neither said so anywhere.
- * "changes things" is not a description of the tool, it is the effect the boundary evaluates,
- * and somebody writing a rule about writes has no way to know that from the badge alone.
- */}
-
- A Bot with a grant may call that tool; a Bot without one is never told
- it exists. Beside each tool is what it does to the far end, which is
- what a boundary rule means by mcp.effect. Anything not
- positively known to be read-only counts as changes things,
- so every tool on a server somebody added by URL is treated as a write
- until it is reviewed. Every call is checked against the boundary and
- written to Audit whichever way it goes.
-
-
- {/*
- * A ROW PER TOOL, NOT A GRID OF TOOLS AGAINST BOTS. The matrix put one checkbox column per
- * Bot, so it grew a column every time somebody made a Bot and was already scrolling
- * sideways at four of them — and sideways is where a grant quietly goes unread. The
- * toggles wrap under the tool they belong to instead, which is how grants are shown
- * everywhere else in the app.
- */}
- {server.tools.length === 0 ? (
-
- No tools listed. Refresh to ask the server again.
-
+
+
+
+ {/*
+ * The effect, not a description. It is what a boundary written about writes
+ * evaluates, and an operator writing that rule has no other way to know.
+ */}
+
+ {tool.effect === "write" ? "changes things" : "reads"}
+
+
+
+ {index !== server.tools.length - 1 && }
+
+ ))}
+
+ )}
+
+ ) : null}
+
+ {server ? (
+
+
+
+
+
+
+
+ Remove from this deployment
+
+ Every grant on its tools goes with it. Credentials stay in the
+ vault, revoked, so the trail still says what was held.
+
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx
new file mode 100644
index 00000000..1ebd1cb6
--- /dev/null
+++ b/app/src/routes/_authed/admin/plugins/index.tsx
@@ -0,0 +1,237 @@
+import {
+ IconBrandGoogleDrive,
+ IconBrandSlack,
+ IconChevronRight,
+ IconPlug,
+} from "@tabler/icons-react";
+import { useQuery } from "@tanstack/react-query";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import * as React from "react";
+import {
+ PageEmpty,
+ PageRows,
+ PageSection,
+ PageShell,
+} from "@/components/layout/page-shell";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemMedia,
+ ItemTitle,
+} from "@/components/ui/item";
+import { Separator } from "@/components/ui/separator";
+import {
+ type CatalogueItem,
+ connectionsQueryOptions,
+ type PluginServer,
+ pluginsPageQueryOptions,
+} from "@/lib/plugins/queries";
+
+/**
+ * What this deployment can reach, as one list per state.
+ *
+ * This screen used to be three tabs: a catalogue of what could be added, a second tab for what had
+ * been, and skills. Answering one question about one vendor — is Drive available, and what can it
+ * do — meant visiting two of them, and the third was a different kind of thing altogether. Two
+ * lists say the same thing in one read: what is connected, and what else there is.
+ *
+ * Every row goes to that vendor's own page, because what a connector needs configured is not the
+ * same from one vendor to the next. A token, an OAuth client, an instance hostname, and a grant per
+ * tool per Bot do not fit on a row, and the previous screen's attempt to fit them made a page that
+ * scrolled sideways.
+ */
+export const Route = createFileRoute("/_authed/admin/plugins/")({
+ component: RouteComponent,
+});
+
+/**
+ * A vendor's own mark where there is one, and a plug where there is not.
+ *
+ * Tabler ships brands for some of these and not others, and a half-branded list looks broken rather
+ * than partial — so the fallback is a plug for everything unbranded rather than, say, an initial.
+ */
+const MARKS: Record> = {
+ "google-drive": IconBrandGoogleDrive,
+ slack: IconBrandSlack,
+};
+
+const markFor = (key: string) => MARKS[key] ?? IconPlug;
+
+/**
+ * What a connected row says on the right.
+ *
+ * The current answer rather than the field's name, which is what the layout skill asks of a summary:
+ * "4 tools · 2 Bots" tells an administrator where this vendor stands, and "Tools" would not.
+ *
+ * A vendor reached as the person asking is a special case worth its own words. It can be fully
+ * configured — client registered, tools listed — and still answer nothing, because the thing that
+ * reads anything is a grant belonging to whoever is asking. "Not connected" is about you, not about
+ * the deployment.
+ */
+function summaryFor(
+ server: PluginServer,
+ /**
+ * The vendor's auth kind, from the catalogue rather than the server record.
+ *
+ * A server row says what this deployment has stored; whose credential reaches it is a fact about
+ * the vendor. Undefined for a server added by URL, which has no catalogue entry and is therefore
+ * never reached as a person.
+ */
+ auth: CatalogueItem["auth"] | undefined,
+ youConnected: boolean,
+): string {
+ if (auth === "user-oauth" && !youConnected) return "Not connected";
+ if (server.tools.length === 0) return "No tools yet";
+
+ const bots = new Set(server.tools.flatMap((tool) => tool.grantedTo)).size;
+ const tools = `${server.tools.length} ${server.tools.length === 1 ? "tool" : "tools"}`;
+ if (bots === 0) return `${tools} · no Bots`;
+ return `${tools} · ${bots} ${bots === 1 ? "Bot" : "Bots"}`;
+}
+
+function RouteComponent() {
+ const plugins = useQuery(pluginsPageQueryOptions());
+ const connections = useQuery(connectionsQueryOptions());
+
+ const connected = new Set(
+ (connections.data?.connections ?? []).map((row) => row.serverId),
+ );
+ const added = new Set((plugins.data?.servers ?? []).map((s) => s.id));
+ const explore = (plugins.data?.catalogue ?? []).filter(
+ (entry) => !added.has(entry.key),
+ );
+ /** Keyed by catalogue key, which is also the server id, so a row can ask how it is reached. */
+ const authByKey = new Map(
+ (plugins.data?.catalogue ?? []).map((entry) => [entry.key, entry.auth]),
+ );
+
+ return (
+
+ {/* Pending, error, empty, rows — pending first, so no sentence asserts anything mid-fetch. */}
+ {plugins.isPending ? null : plugins.error ? (
+
+ Plugins could not be loaded.
+
+ ) : (
+ <>
+
+ {plugins.data?.servers.length === 0 ? (
+
+ Nothing connected yet. Everything available is below.
+
+ ) : (
+
+ {plugins.data?.servers.map((server, index) => {
+ const Mark = markFor(server.id);
+ return (
+
+ {/*
+ * A real link with no children. `useRender` merges props, and children passed
+ * here replace the row's own — the media, content and actions all vanish and
+ * the row draws empty. Its accessible name comes from the title inside it.
+ */}
+
+ }
+ size="sm"
+ >
+
+
+
+
+ {server.title}
+ {/*
+ * The vendor's last failure takes the description's place when there is
+ * one. A server with no tools and no explanation reads as a server that
+ * offers nothing, which sends somebody looking in the wrong place.
+ */}
+
+ {server.lastError ?? server.summary}
+
+
+
+
+ {summaryFor(
+ server,
+ authByKey.get(server.id),
+ connected.has(server.id),
+ )}
+
+
+
+
+ {index !== (plugins.data?.servers.length ?? 0) - 1 && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+ {explore.length === 0 ? (
+ Everything in the catalogue is connected.
+ ) : (
+
+ {explore.map((entry: CatalogueItem, index) => {
+ const Mark = markFor(entry.key);
+ return (
+
+
+ }
+ size="sm"
+ >
+
+
+
+
+ {entry.title}
+ {entry.summary}
+
+
+
+ Not added
+
+
+
+
+ {index !== explore.length - 1 && }
+
+ );
+ })}
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/app/src/routes/_authed/admin/skills.tsx b/app/src/routes/_authed/admin/skills.tsx
new file mode 100644
index 00000000..9f4d2a6d
--- /dev/null
+++ b/app/src/routes/_authed/admin/skills.tsx
@@ -0,0 +1,267 @@
+import { IconFileText, IconPlus } from "@tabler/icons-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { createFileRoute } from "@tanstack/react-router";
+import * as React from "react";
+import { useState } from "react";
+import {
+ PageEmpty,
+ PageRows,
+ PageSection,
+ PageShell,
+} from "@/components/layout/page-shell";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemFooter,
+ ItemMedia,
+ ItemTitle,
+} from "@/components/ui/item";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import { useBotNames } from "@/lib/agents/bot-names";
+import { agentListQueryOptions } from "@/lib/agents/queries";
+import {
+ removeSkillMutationOptions,
+ saveSkillMutationOptions,
+ setPluginGrantMutationOptions,
+} from "@/lib/plugins/mutations";
+import { pluginsPageQueryOptions } from "@/lib/plugins/queries";
+
+/**
+ * The deployment's skills: named instructions a person invokes with `/` and a Bot follows.
+ *
+ * Its own screen rather than a tab on Plugins, because a skill is not a connector. It adds no
+ * capability at all — it can only ask a Bot to use tools that Bot was already granted, and every one
+ * of those calls is still decided, policy-checked and audited. That is why anybody may write one for
+ * themselves on their own Skills page, while adding an MCP server stays an administrator's decision.
+ * Sitting in a list of vendors made it look like a third kind of thing a Bot could reach.
+ */
+export const Route = createFileRoute("/_authed/admin/skills")({
+ component: RouteComponent,
+});
+
+const EMPTY_DRAFT = { slug: "", title: "", summary: "", instructions: "" };
+
+function RouteComponent() {
+ const queryClient = useQueryClient();
+ const plugins = useQuery(pluginsPageQueryOptions());
+ const { data: agents } = useQuery(agentListQueryOptions());
+ const nameFor = useBotNames();
+
+ const [error, setError] = useState(null);
+ const [writing, setWriting] = useState(false);
+ const [draft, setDraft] = useState(EMPTY_DRAFT);
+
+ const report = { onError: (thrown: Error) => setError(thrown.message) };
+ const saveSkill = useMutation({
+ ...saveSkillMutationOptions(queryClient),
+ ...report,
+ });
+ const removeSkill = useMutation({
+ ...removeSkillMutationOptions(queryClient),
+ ...report,
+ });
+ const setGrant = useMutation({
+ ...setPluginGrantMutationOptions(queryClient),
+ ...report,
+ });
+
+ const bots = (agents ?? []).map((agent: { id: string }) => ({
+ id: agent.id,
+ name: nameFor(agent.id),
+ }));
+ const skills = plugins.data?.skills ?? [];
+
+ return (
+ setWriting(true)} size="lg" type="button">
+
+ Write a skill
+
+ }
+ description="Named instructions anybody here can invoke with a slash. A skill adds no capability: it can only ask a Bot to use what that Bot already holds, and every one of those calls is still decided and recorded."
+ title="Skills"
+ >
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ {plugins.isPending ? null : skills.length === 0 ? (
+ No skills yet.
+ ) : (
+
+ {skills.map((skill, index) => (
+
+
+
+
+
+
+
+
+ /{skill.slug}
+ {" "}
+ {skill.title}
+
+ {skill.summary}
+ {/* A set, so it wraps onto its own line rather than crowding the title. */}
+
+
+
+
+
+
+
+
+ {index !== skills.length - 1 && }
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
From 69249154360c9fd72c7e8643a61b5dd6f6eb97a1 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Fri, 21 Aug 2026 11:22:07 -0300
Subject: [PATCH 11/34] Give each row's icon a tile to sit in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A bare glyph put a 15px icon straight against the row's text, so a list of six had no
fixed left edge for the eye to run down and read as six paragraphs rather than a
list. Each icon now sits in a 36px rounded square with a muted fill and a hairline
border, which gives every row the same anchor whatever its icon.
`RowMark` rather than a class at every call site, because the whole value is that the
tiles are identical: eleven copies of `size-9 rounded-lg border bg-muted/60` is
eleven chances for one to drift, and one tile a pixel out looks broken rather than
varied.
A deliberate deviation from the default row anatomy, which is `ItemMedia
variant="icon"` and nothing else. Recorded in the component's own comment, because
the layout skill asks for a reason when a screen departs from it — and because
`components/ui/item.tsx` is a shadcn file with its own upstream, so a new variant
does not belong there.
---
app/src/components/layout/row-mark.tsx | 34 +++++++++++++++++++
app/src/routes/_authed/admin/plugins/$key.tsx | 34 +++++++++----------
.../routes/_authed/admin/plugins/index.tsx | 10 +++---
app/src/routes/_authed/admin/skills.tsx | 6 ++--
4 files changed, 59 insertions(+), 25 deletions(-)
create mode 100644 app/src/components/layout/row-mark.tsx
diff --git a/app/src/components/layout/row-mark.tsx b/app/src/components/layout/row-mark.tsx
new file mode 100644
index 00000000..f8b4bf15
--- /dev/null
+++ b/app/src/components/layout/row-mark.tsx
@@ -0,0 +1,34 @@
+import type * as React from "react";
+import { ItemMedia } from "@/components/ui/item";
+import { cn } from "@/lib/utils";
+
+/**
+ * A row's leading icon, as a tile rather than a bare glyph.
+ *
+ * A DELIBERATE DEVIATION from the default row anatomy, which is `ItemMedia variant="icon"` and
+ * nothing else. Stated here because the layout skill asks for a reason when a screen departs from it.
+ *
+ * The reason is scanning. `variant="icon"` only sizes the svg, so a 15px glyph sits directly against
+ * the row's text and the eye has no fixed left edge to run down — a list of ten reads as ten
+ * paragraphs. A filled square of a constant size gives every row the same visual anchor whatever
+ * its icon, which is what makes a settings list scannable in one pass.
+ *
+ * One component rather than a class literal at every call site, because the whole value is that the
+ * tiles are identical. Eleven copies of `size-9 rounded-lg border bg-muted/60` is eleven chances for
+ * one of them to drift, and a list with one tile a pixel out looks broken rather than varied.
+ */
+export function RowMark({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx
index 65475fcf..59a8fcc9 100644
--- a/app/src/routes/_authed/admin/plugins/$key.tsx
+++ b/app/src/routes/_authed/admin/plugins/$key.tsx
@@ -18,6 +18,7 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
+import { RowMark } from "@/components/layout/row-mark";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -36,7 +37,6 @@ import {
ItemContent,
ItemDescription,
ItemFooter,
- ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
@@ -210,9 +210,9 @@ function RouteComponent() {
}
size="sm"
>
-
+
-
+
Access token
@@ -236,9 +236,9 @@ function RouteComponent() {
}
size="sm"
>
-
+
-
+
OAuth client
@@ -256,9 +256,9 @@ function RouteComponent() {
{/* Read-only: a value with no chevron, because there is nothing here to change. */}
-
+
-
+
Redirect URI
@@ -282,9 +282,9 @@ function RouteComponent() {
} size="sm">
-
+
-
+
Your account
@@ -311,9 +311,9 @@ function RouteComponent() {
}
size="sm"
>
-
+
-
+
Instance host
@@ -340,9 +340,9 @@ function RouteComponent() {
}
size="sm"
>
-
+
-
+
Vendor documentation
@@ -373,9 +373,9 @@ function RouteComponent() {
{server.tools.map((tool, index) => (
-
+
-
+
{tool.name}
@@ -440,9 +440,9 @@ function RouteComponent() {
-
+
-
+
Remove from this deployment
diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx
index 1ebd1cb6..fd957b10 100644
--- a/app/src/routes/_authed/admin/plugins/index.tsx
+++ b/app/src/routes/_authed/admin/plugins/index.tsx
@@ -13,12 +13,12 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
+import { RowMark } from "@/components/layout/row-mark";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
- ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
@@ -148,9 +148,9 @@ function RouteComponent() {
}
size="sm"
>
-
+
-
+
{server.title}
{/*
@@ -209,9 +209,9 @@ function RouteComponent() {
}
size="sm"
>
-
+
-
+
{entry.title}{entry.summary}
diff --git a/app/src/routes/_authed/admin/skills.tsx b/app/src/routes/_authed/admin/skills.tsx
index 9f4d2a6d..1e995bd9 100644
--- a/app/src/routes/_authed/admin/skills.tsx
+++ b/app/src/routes/_authed/admin/skills.tsx
@@ -9,6 +9,7 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
+import { RowMark } from "@/components/layout/row-mark";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -27,7 +28,6 @@ import {
ItemContent,
ItemDescription,
ItemFooter,
- ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
@@ -114,9 +114,9 @@ function RouteComponent() {
{skills.map((skill, index) => (
-
+
-
+
From 6a64f9c68cb8691b3a0278fa5bc165659a3694e0 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Fri, 21 Aug 2026 11:32:35 -0300
Subject: [PATCH 12/34] Leave one connector in the catalogue, and drop the
tile's border
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two changes.
The tiles lose their border and keep the muted fill, so a row's icon reads as one
soft shape rather than a boxed-in glyph.
The catalogue is Google Drive alone. Atlassian, Box, Slack, Salesforce and
ServiceNow are gone: each was a reviewed source contract for a vendor nobody had
connected, and a screen offering five untried connectors asserts more than this
deployment can stand behind. They are in the history, and re-adding one is a review
of that vendor rather than a revert.
This is a security-relevant edit, not a cosmetic one — the catalogue is what makes a
host admissible, so five hosts this deployment would have talked to it no longer
will. Recorded in the file, along with why `deployment-bearer` stays in the union
with no entry using it: a server added by URL has no catalogue entry, and that is
the branch it falls into.
WHAT THE TESTS LOST. ServiceNow was the only per-instance entry, and removing it
took the anchored-pattern assertions with it — that a prefix, a suffix and a
subdomain are each refused. `PATTERNS` is compiled from the catalogue by key, so a
synthetic entry cannot reach a pattern and there is no way left to exercise the
matching through the public API. The fail-closed half survives and is asserted; the
test says out loud what it no longer covers, so whoever adds the next per-instance
vendor restores the rest with it.
The rest of the churn is fixtures moving to Drive: host admissibility, the frozen
path, effect classification. Two assertions in the store suite needed more than a
rename. Both inferred that the policy had not refused a call from the call failing
at the network, which Drive cannot do — it is reached as the person asking, so it is
refused earlier for want of a connection. They now assert `rule` is null, which is
the property they were reaching for and states it directly rather than inferring it
from an unreachable vendor.
Lint warnings 26 to 20: the removed vendors carried the non-null assertions.
---
app/src/components/layout/row-mark.tsx | 4 +-
.../routes/_authed/admin/plugins/index.tsx | 7 +-
server/src/plugins/catalogue.ts | 111 ++----------------
server/tests/plugin-catalogue.test.ts | 106 +++++++++--------
server/tests/plugin-store.integration.test.ts | 38 +++---
5 files changed, 98 insertions(+), 168 deletions(-)
diff --git a/app/src/components/layout/row-mark.tsx b/app/src/components/layout/row-mark.tsx
index f8b4bf15..783ec962 100644
--- a/app/src/components/layout/row-mark.tsx
+++ b/app/src/components/layout/row-mark.tsx
@@ -14,7 +14,7 @@ import { cn } from "@/lib/utils";
* its icon, which is what makes a settings list scannable in one pass.
*
* One component rather than a class literal at every call site, because the whole value is that the
- * tiles are identical. Eleven copies of `size-9 rounded-lg border bg-muted/60` is eleven chances for
+ * tiles are identical. Eleven copies of `size-9 rounded-lg bg-muted/60` is eleven chances for
* one of them to drift, and a list with one tile a pixel out looks broken rather than varied.
*/
export function RowMark({
@@ -24,7 +24,7 @@ export function RowMark({
return (
> = {
"google-drive": IconBrandGoogleDrive,
- slack: IconBrandSlack,
};
const markFor = (key: string) => MARKS[key] ?? IconPlug;
diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts
index 973700ff..4dc2b33c 100644
--- a/server/src/plugins/catalogue.ts
+++ b/server/src/plugins/catalogue.ts
@@ -92,106 +92,19 @@ export type CatalogueEntry = {
docsUrl: string;
};
+/**
+ * One entry, deliberately.
+ *
+ * Atlassian, Box, Slack, Salesforce and ServiceNow were here and were removed: each was a reviewed
+ * source contract for a vendor nobody had connected, and a screen offering five untried connectors
+ * asserts more than this deployment can stand behind. They are in the history if they are wanted
+ * back, and re-adding one is a review of that vendor rather than a revert.
+ *
+ * `deployment-bearer` therefore has no entry using it. The shape stays because the call path still
+ * needs it: a server an administrator added by URL has no catalogue entry at all, and that is the
+ * branch it falls into.
+ */
export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([
- {
- key: "atlassian",
- title: "Atlassian",
- vendor: "Atlassian",
- summary: "Jira issues and Confluence pages.",
- host: "https://mcp.atlassian.com",
- path: "/v1/mcp/authv2",
- auth: { kind: "deployment-bearer" },
- writeTools: Object.freeze([
- "createJiraIssue",
- "editJiraIssue",
- "transitionJiraIssue",
- "addCommentToJiraIssue",
- "addWorklogToJiraIssue",
- "createConfluencePage",
- "updateConfluencePage",
- "createConfluenceFooterComment",
- "createConfluenceInlineComment",
- ]),
- docsUrl:
- "https://support.atlassian.com/rovo/docs/getting-started-with-the-atlassian-remote-mcp-server/",
- },
- {
- key: "box",
- title: "Box",
- vendor: "Box",
- summary: "Files and folders in Box.",
- host: "https://mcp.box.com",
- path: "/",
- auth: { kind: "deployment-bearer" },
- writeTools: Object.freeze([
- "copy_file",
- "copy_folder",
- "create_folder",
- "create_metadata_template",
- "get_upload_url",
- "move_file",
- ]),
- docsUrl: "https://developer.box.com/guides/box-mcp/remote/",
- },
- {
- key: "slack",
- title: "Slack",
- vendor: "Slack",
- summary: "Search and post in the channels the credential can reach.",
- host: "https://mcp.slack.com",
- path: "/mcp",
- auth: { kind: "deployment-bearer" },
- writeTools: Object.freeze([
- "slack_send_message",
- "slack_send_message_draft",
- "slack_schedule_message",
- "slack_add_reaction",
- "slack_create_conversation",
- "slack_create_canvas",
- "slack_update_canvas",
- ]),
- docsUrl: "https://docs.slack.dev/ai/slack-mcp-server/",
- },
- {
- key: "salesforce",
- title: "Salesforce",
- vendor: "Salesforce",
- summary: "Records on the Salesforce platform.",
- // A shared platform host, which is why the path matters as much as the host here: the frozen
- // path selects one server and nothing else on that host is reachable through this.
- host: "https://api.salesforce.com",
- // Salesforce publishes this server at `/platform/`, with sandbox orgs under
- // `/sandbox/platform/`. A deployment on a sandbox needs the custom-server form.
- path: "/platform/mcp/v1/platform/sobject-all",
- auth: { kind: "deployment-bearer" },
- writeTools: Object.freeze([
- "create_record",
- "update_record",
- "delete_record",
- ]),
- docsUrl:
- "https://developer.salesforce.com/docs/einstein/genai/guide/mcp.html",
- },
- {
- key: "servicenow",
- title: "ServiceNow",
- vendor: "ServiceNow",
- summary: "Records on your own ServiceNow instance.",
- // Per-instance: every customer has their own hostname, so there is no single host to pin and
- // admissibility is an anchored pattern instead. The capture group is the instance label.
- host: null,
- hostPattern:
- "^https://([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)\\.service-now\\.com$",
- path: "/sncapps/mcp-server",
- auth: { kind: "deployment-bearer" },
- writeTools: Object.freeze([
- "create_record",
- "update_record",
- "delete_record",
- ]),
- docsUrl:
- "https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/mcp/concept/mcp-server.html",
- },
{
key: "google-drive",
title: "Google Drive",
diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts
index 0832a16c..94acabb9 100644
--- a/server/tests/plugin-catalogue.test.ts
+++ b/server/tests/plugin-catalogue.test.ts
@@ -19,38 +19,52 @@ import {
describe("which servers this deployment will talk to", () => {
test("a pinned host matches only itself", () => {
- const atlassian = catalogueEntry("atlassian");
- expect(atlassian).not.toBeNull();
- expect(hostAdmissible(atlassian!, "https://mcp.atlassian.com")).toBe(true);
+ const drive = catalogueEntry("google-drive");
+ expect(drive).not.toBeNull();
+ expect(hostAdmissible(drive!, "https://drivemcp.googleapis.com")).toBe(
+ true,
+ );
// A prefix, a suffix and a lookalike are each refused. The suffix case is the one that matters:
// a check written with endsWith rather than equality would accept it.
expect(
- hostAdmissible(atlassian!, "https://mcp.atlassian.com.evil.test"),
+ hostAdmissible(drive!, "https://drivemcp.googleapis.com.evil.test"),
).toBe(false);
expect(
- hostAdmissible(atlassian!, "https://evil.test/mcp.atlassian.com"),
+ hostAdmissible(drive!, "https://evil.test/drivemcp.googleapis.com"),
).toBe(false);
- expect(hostAdmissible(atlassian!, "http://mcp.atlassian.com")).toBe(false);
+ expect(hostAdmissible(drive!, "http://drivemcp.googleapis.com")).toBe(
+ false,
+ );
});
- test("a per-instance vendor accepts its own instances and nothing else", () => {
- const servicenow = catalogueEntry("servicenow");
- expect(servicenow).not.toBeNull();
- expect(hostAdmissible(servicenow!, "https://acme.service-now.com")).toBe(
- true,
- );
- expect(
- hostAdmissible(servicenow!, "https://acme-dev1.service-now.com"),
- ).toBe(true);
- // Anchored at both ends, so neither a prefix nor a suffix gets in.
- expect(
- hostAdmissible(servicenow!, "https://acme.service-now.com.evil.test"),
- ).toBe(false);
- expect(
- hostAdmissible(servicenow!, "https://evil.test#acme.service-now.com"),
- ).toBe(false);
- // A subdomain of an instance is not an instance.
- expect(hostAdmissible(servicenow!, "https://a.b.service-now.com")).toBe(
+ test("an entry whose pattern this build never compiled is refused", () => {
+ /*
+ * WHAT THIS NO LONGER COVERS. ServiceNow was the only per-instance entry, and removing it took
+ * the anchored-pattern assertions with it — that a prefix, a suffix and a subdomain are each
+ * refused. `PATTERNS` is compiled from the catalogue by key, so a synthetic entry cannot reach a
+ * pattern and there is no way left to exercise the matching itself through the public API.
+ *
+ * What survives is the fail-closed half, which is worth keeping on its own: an entry claiming to
+ * be per-instance that this build has no pattern for is refused rather than admitted. Whoever
+ * adds the next per-instance vendor should restore the anchoring cases with it.
+ */
+ const perInstance = {
+ key: "google-drive",
+ title: "Per-instance vendor",
+ vendor: "Example",
+ summary: "",
+ host: null,
+ hostPattern:
+ "^https://([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)\\.service-now\\.com$",
+ path: "/mcp",
+ auth: { kind: "deployment-bearer" },
+ writeTools: [],
+ docsUrl: "",
+ } as const;
+
+ // `PATTERNS` is compiled from the catalogue by key, so a synthetic entry reaches no pattern and
+ // is refused outright. That is itself the fail-closed property: no pattern means no.
+ expect(hostAdmissible(perInstance, "https://acme.service-now.com")).toBe(
false,
);
});
@@ -61,21 +75,13 @@ describe("which servers this deployment will talk to", () => {
});
test("the path is the catalogue's, never the caller's", () => {
- // A per-instance vendor is the only case where a caller supplies any part of the address, and
- // even then the path is fixed, so an admissible host cannot reach another endpoint.
- const resolved = resolveServerUrl(
- "servicenow",
- "https://acme.service-now.com",
- );
- expect(resolved?.url).toBe(
- "https://acme.service-now.com/sncapps/mcp-server",
+ // An instance host offered for a vendor with a pinned host is ignored, not honoured: the host and
+ // the path both come from the entry, so nothing a caller sends can reach another endpoint.
+ expect(resolveServerUrl("google-drive", "https://evil.test").url).toBe(
+ "https://drivemcp.googleapis.com/mcp/v1",
);
});
- test("a per-instance vendor with no instance supplied resolves to nothing", () => {
- expect(resolveServerUrl("servicenow")).toBeNull();
- });
-
test("every catalogue entry pins a host or an anchored pattern", () => {
for (const entry of CATALOGUE) {
if (entry.host === null) {
@@ -116,7 +122,9 @@ describe("whose credential a server uses", () => {
}
});
- test("the five token vendors did not quietly become user-oauth", () => {
+ test("a vendor this build has never heard of is not an entry", () => {
+ // The five bearer vendors that used to be asserted here are gone. What matters now is the same
+ // property from the other side: a key with no entry resolves to nothing rather than to a default.
for (const key of [
"atlassian",
"box",
@@ -124,7 +132,8 @@ describe("whose credential a server uses", () => {
"salesforce",
"servicenow",
]) {
- expect(catalogueEntry(key)?.auth.kind).toBe("deployment-bearer");
+ expect(catalogueEntry(key)).toBeNull();
+ expect(resolveServerUrl(key)).toBeNull();
}
});
});
@@ -162,32 +171,31 @@ describe("Google Drive", () => {
});
describe("what a tool does", () => {
- const atlassian = catalogueEntry("atlassian")!;
+ const drive = catalogueEntry("google-drive")!;
test("a named write is a write", () => {
- expect(classifyTool(atlassian, "createJiraIssue", true)).toBe("write");
+ expect(classifyTool(drive, "create_file", true)).toBe("write");
});
test("an advertised tool that is not a named write is a read", () => {
- expect(classifyTool(atlassian, "searchJiraIssues", true)).toBe("read");
+ expect(classifyTool(drive, "search_files", true)).toBe("read");
});
test("a tool the server never advertised is a write", () => {
// The only thing that produced this name was a model, so nothing has vouched for it.
- expect(classifyTool(atlassian, "searchJiraIssues", false)).toBe("write");
+ expect(classifyTool(drive, "search_files", false)).toBe("write");
});
test("every tool on a server nobody reviewed is a write", () => {
expect(classifyTool(null, "anything_at_all", true)).toBe("write");
});
- test("a tool that edits rather than creates is still a write", () => {
- // The naming does not carry it: "update" and "create" both change somebody else's system, and a
- // list built by reading verbs off tool names lets the edits through.
- const slack = catalogueEntry("slack")!;
- expect(classifyTool(slack, "slack_update_canvas", true)).toBe("write");
- expect(classifyTool(slack, "slack_create_canvas", true)).toBe("write");
- expect(classifyTool(slack, "slack_read_canvas", true)).toBe("read");
+ test("copying is a write, and reading a file's content is not", () => {
+ // `copy_file` is the case a list built by reading verbs off tool names would miss: it creates
+ // nothing named "create" and still puts a new object in somebody's Drive.
+ expect(classifyTool(drive, "copy_file", true)).toBe("write");
+ expect(classifyTool(drive, "read_file_content", true)).toBe("read");
+ expect(classifyTool(drive, "get_file_metadata", true)).toBe("read");
});
});
diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts
index 424764ee..66403e00 100644
--- a/server/tests/plugin-store.integration.test.ts
+++ b/server/tests/plugin-store.integration.test.ts
@@ -32,8 +32,8 @@ const database = createDatabase(
const suite = randomUUID().slice(0, 8);
const holderId = `agent_plugin_holder_${suite}`;
const strangerId = `agent_plugin_stranger_${suite}`;
-const serverId = `atlassian`;
-const toolName = "searchJiraIssues";
+const serverId = "google-drive";
+const toolName = "search_files";
const ref = `${serverId}/${toolName}`;
let policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] };
@@ -108,15 +108,15 @@ beforeAll(async () => {
.insert(mcpServers)
.values({
id: serverId,
- title: "Atlassian",
- vendor: "Atlassian",
- url: "https://mcp.atlassian.com/v1/mcp/authv2",
+ title: "Google Drive",
+ vendor: "Google",
+ url: "https://drivemcp.googleapis.com/mcp/v1",
provenance: "first-party",
})
.onConflictDoNothing();
await database
.insert(mcpTools)
- .values({ serverId, name: toolName, description: "Search issues." })
+ .values({ serverId, name: toolName, description: "Search files." })
.onConflictDoNothing();
});
@@ -172,7 +172,7 @@ describe("a grant is the permission", () => {
const held = await store.listForAgent(holderId);
expect(held.tools.map((tool) => tool.ref)).toEqual([ref]);
// The name the model is offered, which may not contain a slash.
- expect(held.tools[0].toolName).toBe("mcp__atlassian__searchJiraIssues");
+ expect(held.tools[0].toolName).toBe("mcp__google-drive__search_files");
const nothing = await store.listForAgent(strangerId);
expect(nothing.tools).toEqual([]);
@@ -185,7 +185,7 @@ describe("the policy is asked as well as the grant", () => {
await store.grant("mcp", ref, holderId, "admin@openbot.local");
policy = {
mode: "enforce",
- deny: ['mcp.server == "atlassian"'],
+ deny: ['mcp.server == "google-drive"'],
allow: ["true"],
};
@@ -206,7 +206,7 @@ describe("the policy is asked as well as the grant", () => {
expect(thrown).toBeInstanceOf(PluginRefusedError);
// The rule that decided it, so an operator reading the refusal knows what to edit.
expect((thrown as PluginRefusedError).rule).toBe(
- 'mcp.server == "atlassian"',
+ 'mcp.server == "google-drive"',
);
const rows = await auditRowsFor(ref);
@@ -214,14 +214,14 @@ describe("the policy is asked as well as the grant", () => {
(row) =>
row.eventType === "mcp.call_rejected" &&
(row.payload as { decision?: { rule?: string } }).decision?.rule ===
- 'mcp.server == "atlassian"',
+ 'mcp.server == "google-drive"',
);
expect(refusedByPolicy.length).toBeGreaterThan(0);
});
test("a rule can speak about effect rather than about tool names", async () => {
await store.grant("mcp", ref, holderId, "admin@openbot.local");
- // `searchJiraIssues` is advertised and is not in the vendor's write list, so it is a read and
+ // `search_files` is advertised and is not in the vendor's write list, so it is a read and
// this deny rule must NOT catch it. The assertion is that the call gets past the policy, which
// it proves by failing at the network instead of as a refusal.
policy = {
@@ -244,7 +244,15 @@ describe("the policy is asked as well as the grant", () => {
policy = { mode: "enforce", deny: [], allow: ["true"] };
}
- expect(thrown).not.toBeInstanceOf(PluginRefusedError);
+ /*
+ * NOT REFUSED BY THE RULE. The call is still refused, because this vendor is reached as the
+ * person asking and nobody has connected — but `rule` is null, which is the assertion: no
+ * expression decided this. Asserting the absence of a refusal outright would only prove the
+ * vendor was unreachable, which was always the weaker claim.
+ */
+ expect(thrown).toBeInstanceOf(PluginRefusedError);
+ expect((thrown as PluginRefusedError).rule).toBeNull();
+ expect((thrown as PluginRefusedError).message).toContain("connected");
});
});
@@ -282,8 +290,10 @@ describe("a boundary written about the browser does not refuse tool calls", () =
policy = { mode: "enforce", deny: [], allow: ["true"] };
}
- // Not a refusal. It gets as far as the network, which is where this test stops caring.
- expect(thrown).not.toBeInstanceOf(PluginRefusedError);
+ // The rule did not decide this: `rule` is null. What refuses it is the missing connection for a
+ // vendor reached as the person asking, which is a different sentence and a different cause.
+ expect((thrown as PluginRefusedError).rule).toBeNull();
+ expect((thrown as PluginRefusedError).message).toContain("connected");
});
});
From f53b2beb7c960367d180af8f3f3172e4d5953381 Mon Sep 17 00:00:00 2001
From: Guido Vizoso
Date: Fri, 21 Aug 2026 11:54:10 -0300
Subject: [PATCH 13/34] Make enabling a connector a switch, and keep the tile
for third parties
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two changes to the plugin detail screen.
Enabling is now one switch instead of an "Add to deployment" button at the top of
the page and a destructive "Remove" row at the bottom. Those were the same decision
drawn twice, in two places, one of them looking far more dangerous than the other.
A Switch is what the layout skill reserves for exactly this: binary, immediate, no
save. The description states the consequence in the present tense in both
directions, because switching it off deletes every grant on the vendor's tools and
switching it back on does not bring them back.
Everything below it is now gated on being enabled. A vendor that is off shows one
row, which is all there is to decide about it; the client, the redirect URI and the
tools appear once it is on. That also fixes an ordering trap in the old flow — an
OAuth client is recorded against the server row, so it could not be registered
before the row existed, and the page previously offered both at once.
The tile is now for one list only: the connectors on `admin/plugins`. Every row
there is another company and the tile carries that vendor's own mark, which is work
no other row in the app needs. A detail page's rows are this deployment's own
settings and a skill is an instruction we wrote — no third party to identify, so
both go back to the standard `ItemMedia variant="icon"` and stay consistent with the
other screens. Recorded in `RowMark` so the next reader knows the narrowness is
deliberate rather than incomplete.
The switch row itself takes no icon at all. It is this deployment's own control, not
a thing to tell apart from other things.
Not tested beyond typecheck, lint, build and a look at the screen: the frontend has
no tests. The toggle's off path in particular has not been exercised — the vendor on
this deployment was enabled by hand outside this session, and I left it alone rather
than switching it off to watch what happens.
---
app/src/components/layout/row-mark.tsx | 16 +-
app/src/routes/_authed/admin/plugins/$key.tsx | 371 +++++++++---------
app/src/routes/_authed/admin/skills.tsx | 6 +-
3 files changed, 203 insertions(+), 190 deletions(-)
diff --git a/app/src/components/layout/row-mark.tsx b/app/src/components/layout/row-mark.tsx
index 783ec962..b7fdbbab 100644
--- a/app/src/components/layout/row-mark.tsx
+++ b/app/src/components/layout/row-mark.tsx
@@ -8,14 +8,18 @@ import { cn } from "@/lib/utils";
* A DELIBERATE DEVIATION from the default row anatomy, which is `ItemMedia variant="icon"` and
* nothing else. Stated here because the layout skill asks for a reason when a screen departs from it.
*
- * The reason is scanning. `variant="icon"` only sizes the svg, so a 15px glyph sits directly against
- * the row's text and the eye has no fixed left edge to run down — a list of ten reads as ten
- * paragraphs. A filled square of a constant size gives every row the same visual anchor whatever
- * its icon, which is what makes a settings list scannable in one pass.
+ * FOR ONE LIST ONLY: the connectors on `admin/plugins`. Every row there is another company, and the
+ * tile carries that vendor's own mark — so it is doing work no other row in the app needs, which is
+ * telling third parties apart at a glance. `variant="icon"` puts a 15px glyph straight against the
+ * text, and a list of vendors read that way has no fixed left edge for the eye to run down.
+ *
+ * Not for a detail page, and not for skills. Those rows are this deployment's own settings and its
+ * own instructions; there is no third party to identify, so they take the standard media and the
+ * screens stay consistent with the other eleven that use it.
*
* One component rather than a class literal at every call site, because the whole value is that the
- * tiles are identical. Eleven copies of `size-9 rounded-lg bg-muted/60` is eleven chances for
- * one of them to drift, and a list with one tile a pixel out looks broken rather than varied.
+ * tiles are identical: copies of `size-9 rounded-lg bg-muted/60` are chances for one of them to
+ * drift, and a list with one tile a pixel out looks broken rather than varied.
*/
export function RowMark({
className,
diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx
index 59a8fcc9..f74aadc0 100644
--- a/app/src/routes/_authed/admin/plugins/$key.tsx
+++ b/app/src/routes/_authed/admin/plugins/$key.tsx
@@ -5,7 +5,6 @@ import {
IconKey,
IconServer,
IconTool,
- IconTrash,
IconUserCheck,
} from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -18,7 +17,6 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
-import { RowMark } from "@/components/layout/row-mark";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -37,9 +35,11 @@ import {
ItemContent,
ItemDescription,
ItemFooter,
+ ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
import { useBotNames } from "@/lib/agents/bot-names";
import { agentListQueryOptions } from "@/lib/agents/queries";
import { storeMcpToken } from "@/lib/credentials/mutations";
@@ -178,11 +178,7 @@ function RouteComponent() {
>
Refresh tools
- ) : (
-
- )
+ ) : undefined
}
backButton={{ label: "Plugins", linkProps: { to: "/admin/plugins" } }}
description={entry?.summary ?? server?.summary}
@@ -194,169 +190,211 @@ function RouteComponent() {
) : null}
-
+
- {auth === "deployment-bearer" ? (
- setDialog("token")} type="button" />
- }
- size="sm"
- >
-
-
-
-
- Access token
-
- Sent as a bearer token on every call to this vendor.
-
-
-
-
- {server?.hasCredential ? "Held" : "Not set"}
-
-
-
-
- ) : null}
+ {/*
+ * Binary and immediate, which is what the layout skill reserves a Switch for: it takes
+ * effect when switched and there is no save. It replaces an "Add to deployment" button and
+ * a destructive "Remove" row that were the same decision drawn twice, in two places, one of
+ * them looking far more dangerous than the other.
+ *
+ * The description states the consequence in the present tense, in both directions, because
+ * switching this off deletes every grant on the vendor's tools and that is not recoverable
+ * by switching it back on.
+ */}
+ {/* No leading icon: the row is this deployment's own switch, not a thing to identify. */}
+
+
+ Enable for this deployment
+
+ {server
+ ? "Bots may be granted its tools. Switching this off removes it and every grant on its tools."
+ : "No Bot can reach this vendor. Switch it on to configure it and grant its tools."}
+
+
+
+ {
+ setError(null);
+ if (next) void add();
+ else remove.mutate(key);
+ }}
+ />
+
+
+
+
- {auth === "user-oauth" ? (
- <>
+ {server ? (
+
+
+ {auth === "deployment-bearer" ? (
setDialog("client")} type="button" />
+
-
- {/* Read-only: a value with no chevron, because there is nothing here to change. */}
-
-
-
-
-
- Redirect URI
-
- Add this to the client's authorised redirect URIs at the
- vendor, exactly as written. A single wrong character fails
- there, with a message that does not mention OpenBot.
-
-
- {plugins.data?.redirectUri ? (
-
- {plugins.data.redirectUri}
-
- ) : (
-
- This deployment has no public URL, so nobody can
- complete a consent flow. Set OPENBOT_PUBLIC_URL.
-
- )}
-
-
-
-
- } size="sm">
-
-
-
-
- Your account
-
- Yours to grant and yours to withdraw, in Preferences. Nobody
- can connect it for you.
-
-
-
-
- {youConnected ? "Connected" : "Not connected"}
-
-
-
-
- >
- ) : null}
+ ) : null}
- {entry?.perInstance ? (
- <>
-
- setDialog("instance")} type="button" />
- }
- size="sm"
- >
-
-
-
-
- Instance host
-
- This vendor gives every customer their own hostname, checked
- against its pattern before anything is stored.
-
-
-
-
- {server?.url ?? "Not set"}
-
-
-
-
- >
- ) : null}
+ {auth === "user-oauth" ? (
+ <>
+ setDialog("client")} type="button" />
+ }
+ size="sm"
+ >
+
+
+
+
+ OAuth client
+
+ Identifies this deployment to the vendor. It reaches
+ nobody's documents on its own.
+
+
+
+
+ {server?.hasCredential ? "Registered" : "Not registered"}
+
+
+
+
+
+ {/* Read-only: a value with no chevron, because there is nothing here to change. */}
+
+
+
+
+
+ Redirect URI
+
+ Add this to the client's authorised redirect URIs at the
+ vendor, exactly as written. A single wrong character fails
+ there, with a message that does not mention OpenBot.
+
+
+ {plugins.data?.redirectUri ? (
+
+ {plugins.data.redirectUri}
+
+ ) : (
+
+ This deployment has no public URL, so nobody can
+ complete a consent flow. Set OPENBOT_PUBLIC_URL.
+
+ )}
+
+
+
+
+ } size="sm">
+
+
+
+
+ Your account
+
+ Yours to grant and yours to withdraw, in Preferences.
+ Nobody can connect it for you.
+
+
+
+
+ {youConnected ? "Connected" : "Not connected"}
+
+
+
+
+ >
+ ) : null}
- {entry?.docsUrl ? (
- <>
-
-
- }
- size="sm"
- >
-
-
-
-
- Vendor documentation
-
- What this server offers, from the people who maintain it.
-
-
-
-
-
-
- >
- ) : null}
-
-
+ {entry?.perInstance ? (
+ <>
+
+ setDialog("instance")}
+ type="button"
+ />
+ }
+ size="sm"
+ >
+
+
+
+
+ Instance host
+
+ This vendor gives every customer their own hostname,
+ checked against its pattern before anything is stored.
+
+
+
+
+ {server?.url ?? "Not set"}
+
+
+
+
+ >
+ ) : null}
+
+ {entry?.docsUrl ? (
+ <>
+
+
+ }
+ size="sm"
+ >
+
+
+
+
+ Vendor documentation
+
+ What this server offers, from the people who maintain it.
+
+
+
+
+
+
+ >
+ ) : null}
+
+
+ ) : null}
{server ? (
(
-
+
-
+
{tool.name}
@@ -436,35 +474,6 @@ function RouteComponent() {
) : null}
- {server ? (
-
-
-
-
-
-
-
- Remove from this deployment
-
- Every grant on its tools goes with it. Credentials stay in the
- vault, revoked, so the trail still says what was held.
-
-
-
-
-
-
-
-
- ) : null}
-