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/.env.example b/.env.example
index 126017d3..38417a98 100644
--- a/.env.example
+++ b/.env.example
@@ -51,6 +51,19 @@ OPENBOT_SINGLE_USER=true
# OKTA_OAUTH_CLIENT_SECRET=
# OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default
+# 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
+
+# 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
TRUSTED_ORIGINS=http://localhost:3010
# CopilotKit Intelligence. Required: the server refuses to start without all four because
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8bc3681..387f75a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,25 @@ digit. The same rule container and volume names have always followed. A deployme
`AUDIT_RETENTION_DAYS` is new and unset, which keeps the audit trail forever, as before. Set it to a
whole number of days to have old rows removed.
+**An MCP server pointed at a credential that no longer exists loses the pointer.** `mcp_servers`
+now names its credential with a real foreign key, where the column was `text` against a `uuid`
+primary key with nothing checking it — so a deployment is allowed to be holding a pointer to a vault
+row that was deleted underneath it, and the screens read as though the server were still configured.
+The migration clears those before adding the key, because it cannot add it otherwise. If this
+happens, that connector correctly reports having no credential and an administrator registers it
+again; nothing else is affected, and a deployment with no such pointer sees nothing.
+
+**The old Google Drive connector is gone, and it is not the new one renamed.** It configured a
+service account with domain impersonation and had the worker sync documents into a local pgvector
+index guarded by our own ACL rows, so 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.
+`/admin/connectors` and its two screens, the connector catalogue and admin service, the sync
+persistence and the worker's connector runner have all been removed. A deployment that was syncing
+this way stops syncing and should enable the new connector at `/admin/plugins/google-drive`, where
+each person connects their own account.
+
+`knowledge.yaml` is still parsed and still refused when malformed, because it is part of the
+deployment-package contract. Its `sources:` are now read by nothing.
`MANAGED_AGENT_AG_UI_URL` is no longer required to start. The one-container image does not carry a
Bot, so requiring it registered the shipped Risk Analyst against a host that was not there and every
conversation with it failed. Leave it unset for that image. A laptop `scripts/start.sh` still points
@@ -63,6 +82,46 @@ Sessions survive and nobody signs in again.
and skips routing entirely. If the router is uncertain or unreachable, it falls back to the same
default the composer always used, and says so, rather than misroute or drop.
+- **A Bot can answer from Google Drive, as the person asking.** Ask a Bot a question whose answer is
+ in a document and it answers from the live file rather than from an index, citing a link that opens
+ it. A Bot granted these tools reads Drive on the asker's own grant, so two people asking the same
+ question get the answers their own accounts can see, and neither sees the other's documents.
+ Read-only: the scope requested is `drive.readonly`, so a write is refused by Google before this
+ deployment has to. Nothing is cached — the refresh token is stored and an access token is minted
+ per call, so revoking access at Google takes effect on the next one rather than when a cache
+ expires.
+
+ Setting it up takes two people and neither can do the other's half. An administrator registers a
+ Google Cloud OAuth client and enables the connector at `/admin/plugins/google-drive`; each person
+ then connects their own account, and there is deliberately no endpoint for an administrator to
+ connect one on somebody's behalf. The redirect URI has to match what is registered character for
+ character, and the connector page states the exact string to paste, because a mismatch fails at
+ Google with a message that never mentions OpenBot. See
+ [docs/plugins/google-drive.md](docs/plugins/google-drive.md) for the whole setup and for what each
+ failure means.
+
+ **Disconnecting is not built yet.** The account page says so and points at Google's own third-party
+ access settings, which is what withdraws it today.
+- **Each tool a connector offers has its own screen**, at `/admin/plugins//tools/`,
+ with a switch per Bot. The connector page previously drew a button per Bot inside every tool row,
+ which is a control per Bot per tool stacked in one list, and grew without bound as Bots were added.
+- **Connected accounts**, at `/settings/connected-accounts`. What a Bot may read as you, and the
+ scope the vendor actually granted rather than the one that was asked for.
+- **A tool result that found nothing says so.** An empty result used to reach the model as an empty
+ string, which reads as "the tool had nothing to say" rather than "there is nothing there" — and a
+ model closes that gap from memory, which for a knowledge connector is the failure worth preventing.
+- **The shipped Knowledge Bot answers from the tools it has.** Its instructions in
+ `examples/fintech` told it to say no source was connected, which was honest when none could be:
+ the connector this replaces had been removed and nothing had taken its place. With a connector
+ granted it became the opposite of honest — the Bot called a tool, was handed a file listing, and
+ said it had no access anyway. It now reports what its tools return, says so plainly when it has no
+ tool or a tool reports a problem, and does neither of the two things worth forbidding: answering
+ from its own memory as though it came from a source, or claiming to lack access to something a tool
+ has just returned. A deployment with its own tenant package is unaffected.
+- **`mcp.call_failed`.** A call this deployment permitted and the vendor did not complete now leaves
+ a row of its own, carrying the vendor's own sentence. `mcp.call_succeeded` was written before the
+ network call rather than after, so a call that died at the vendor recorded success and the Admin
+ page agreed with it.
- **Releases are cut by a workflow, not by hand.** `Create release PR` bumps the version and promotes
`## Unreleased` to a numbered section; merging the pull request it opens is what publishes. Merging
builds and pushes one image to `ghcr.io/copilotkit/openbot`, signs a build provenance attestation
@@ -125,6 +184,16 @@ Sessions survive and nobody signs in again.
is unavailable never blocks a sign-in.
### Fixed
+- **Removing somebody left the credentials they had granted this deployment sitting in the vault.**
+ Removing them from the People screen ended their sessions and stopped the next sign-in, and left the
+ refresh token behind, unrevoked. They could not use it — the account comes from a session they no
+ longer get — but "we removed their access" was not true of the token, which for a connector read as
+ the person asking is the part that matters. Removing somebody now retires it, and each retirement is
+ on the audit trail as `mcp.account_disconnected`. Deleting a person's row used to be worse, because
+ it took the connection record with it and left the credential reachable by nothing at all; those are
+ found and retired too. This stops the deployment holding a usable secret. It does **not** withdraw
+ the grant at the vendor, which needs revoking there until disconnect ships, and the audit row says
+ which of the two happened rather than implying both.
- **The one-container image registered a coworker it could not run.** `MANAGED_AGENT_AG_UI_URL`
defaulted to `localhost:4201` and was required, so Risk Analyst appeared on the roster and every
conversation with it failed. The URL is optional; the package omits that coworker when it is
diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx
index 6bda3847..47aa5b46 100644
--- a/app/src/components/admin/admin-sidebar.tsx
+++ b/app/src/components/admin/admin-sidebar.tsx
@@ -3,10 +3,10 @@ import {
IconBuildingBank,
IconCode,
IconDeviceDesktop,
+ IconFileText,
IconKey,
IconLayoutGrid,
IconListDetails,
- IconPlugConnected,
IconPuzzle,
IconShieldCheck,
IconUsers,
@@ -29,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 eight 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 eight 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;
@@ -46,11 +46,6 @@ const GROUPS: {
{
label: "What Bots can reach",
items: [
- {
- title: "Connectors",
- icon: IconPlugConnected,
- linkOptions: { to: "/admin/connectors" },
- },
{
title: "Credentials",
icon: IconKey,
@@ -76,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/components/layout/row-mark.tsx b/app/src/components/layout/row-mark.tsx
new file mode 100644
index 00000000..b7fdbbab
--- /dev/null
+++ b/app/src/components/layout/row-mark.tsx
@@ -0,0 +1,38 @@
+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.
+ *
+ * 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: 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,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
diff --git a/app/src/components/settings/settings-sidebar.tsx b/app/src/components/settings/settings-sidebar.tsx
index 5a99b398..9eb1730b 100644
--- a/app/src/components/settings/settings-sidebar.tsx
+++ b/app/src/components/settings/settings-sidebar.tsx
@@ -1,6 +1,7 @@
import {
IconArrowLeft,
IconLayoutGrid,
+ IconPlug,
IconSettings,
} from "@tabler/icons-react";
import { Link, type LinkOptions } from "@tanstack/react-router";
@@ -37,6 +38,15 @@ const ITEMS: {
exact: true,
linkOptions: { to: "/settings" },
},
+ {
+ /*
+ * The same subject as Admin's Plugins, from the other side: there an administrator decides what
+ * this deployment may reach at all, here you decide what it may reach as you.
+ */
+ title: "Connected accounts",
+ icon: IconPlug,
+ linkOptions: { to: "/settings/connected-accounts" },
+ },
{
/* The same mark Admin gives UI Components. It is the same subject seen from the other side. */
title: "Components gallery",
diff --git a/app/src/lib/connectors/mutations.ts b/app/src/lib/connectors/mutations.ts
deleted file mode 100644
index 0c627f80..00000000
--- a/app/src/lib/connectors/mutations.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { mutationOptions, type QueryClient } from "@tanstack/react-query";
-import { client } from "@/lib/client";
-import { connectorKeys } from "./queries";
-
-/** What Google Drive needs before it can read anything on a deployment's behalf. */
-export type GoogleDriveSetupInput = {
- serviceAccountJson: string;
- impersonationSubject: string;
-};
-
-/**
- * Configure the Google Drive connector.
- *
- * The service account JSON is a credential, so it goes one way only: it is sent here and never read
- * back. What a later read returns is whether the connector is configured, not what it was configured
- * with.
- */
-export function setUpGoogleDriveMutationOptions(queryClient: QueryClient) {
- return mutationOptions({
- mutationFn: async (input: GoogleDriveSetupInput) => {
- await client("/api/admin/connectors/google-drive/setup", {
- method: "POST",
- body: input,
- fallback: "Could not set up Google Drive",
- });
- },
- onSuccess: () =>
- queryClient.invalidateQueries({ queryKey: connectorKeys.all }),
- });
-}
diff --git a/app/src/lib/connectors/queries.ts b/app/src/lib/connectors/queries.ts
deleted file mode 100644
index 6f3d984f..00000000
--- a/app/src/lib/connectors/queries.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { queryOptions } from "@tanstack/react-query";
-import { client } from "@/lib/client";
-
-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 => {
- return client("/api/admin/connectors", "connectors", {
- fallback: "Could not load connectors",
- });
- },
- });
-}
diff --git a/app/src/lib/markdown.tsx b/app/src/lib/markdown.tsx
index 0342968e..1dae79d0 100644
--- a/app/src/lib/markdown.tsx
+++ b/app/src/lib/markdown.tsx
@@ -1,3 +1,9 @@
+import {
+ IconFile,
+ IconFileText,
+ IconPresentation,
+ IconTable,
+} from "@tabler/icons-react";
import type { ComponentProps } from "react";
/**
@@ -6,16 +12,97 @@ import type { ComponentProps } from "react";
* Links open in a new tab with `noreferrer` because content can come from a model or remote MCP
* server.
*/
+
+/**
+ * A document this deployment can recognise, drawn as a chip rather than as underlined text.
+ *
+ * WHY A CHIP. A knowledge answer is mostly a claim plus the thing it came from, and those two want
+ * to look different. Underlined blue text in the middle of a sentence reads as "more about this";
+ * a chip with the file's own type on it reads as "this is the document", which is the whole point of
+ * a connector that answers from a live system. It also survives the model's phrasing: whether it
+ * writes "I found it in X" or lists three files, each one is drawn the same way.
+ *
+ * Recognition is by URL, and only Google's own document hosts. Anything else is an ordinary link,
+ * because a chip asserts "this is a file in a system you have connected" and that is not something
+ * to claim about a URL a model wrote.
+ */
+const DRIVE_KINDS = [
+ { match: "/document/", icon: IconFileText, label: "Doc" },
+ { match: "/spreadsheets/", icon: IconTable, label: "Sheet" },
+ { match: "/presentation/", icon: IconPresentation, label: "Slides" },
+] as const;
+
+function driveKind(href: string | undefined) {
+ if (!href) return null;
+
+ let url: URL;
+ try {
+ url = new URL(href);
+ } catch {
+ // A relative or malformed href is not a Drive document, and is not worth throwing over.
+ return null;
+ }
+
+ /*
+ * Exact hosts, never a suffix test. `docs.google.com.evil.test` ends with the string and is
+ * somebody else's domain, and a chip is a statement that this is a real file in a real connected
+ * system — the one kind of link where dressing up an impostor does actual harm.
+ */
+ if (url.protocol !== "https:") return null;
+ if (url.hostname === "docs.google.com") {
+ const kind = DRIVE_KINDS.find((entry) =>
+ url.pathname.includes(entry.match),
+ );
+ // A docs.google.com URL of some other shape is still a Drive document, just not one of the three.
+ return kind ?? { match: "", icon: IconFile, label: "Drive" };
+ }
+ if (url.hostname === "drive.google.com") {
+ return { match: "", icon: IconFile, label: "Drive" };
+ }
+ return null;
+}
+
export const markdownComponents = {
- a: ({ href, children, ...rest }: ComponentProps<"a">) => (
-
- {children}
-
- ),
+ a: ({ href, children, ...rest }: ComponentProps<"a">) => {
+ const kind = driveKind(href);
+
+ if (kind) {
+ const Icon = kind.icon;
+ return (
+
+
+ {/* Truncated rather than wrapped: a long file name should not reflow the sentence around it. */}
+ {children}
+ {/*
+ * The type, after the name. It answers "can I open this, and with what" without the reader
+ * hovering to read a URL, and it is the part a file name often leaves out.
+ */}
+ {kind.label}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ },
};
diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts
index 126fb305..e2e2228c 100644
--- a/app/src/lib/plugins/mutations.ts
+++ b/app/src/lib/plugins/mutations.ts
@@ -158,6 +158,70 @@ export function saveSkillMutationOptions(queryClient: QueryClient) {
});
}
+/** The deployment's OAuth client for a vendor reached as the person asking. */
+export type OAuthClientInput = {
+ serverId: string;
+ clientId: string;
+ clientSecret: string;
+};
+
+/**
+ * Register the deployment's OAuth client for a `user-oauth` server.
+ *
+ * Its own write rather than a field on the curated-server input, because it has its own lifetime: a
+ * client is rotated without the server being re-added, and re-adding a server should not mean
+ * re-typing a client. It is also recorded against the server row, so it can only happen once that
+ * row exists — which is why the page chains it rather than sending both at once.
+ *
+ * Nobody's documents are reachable with what this sends. A client identifies this deployment to the
+ * vendor; the grant that reads anything belongs to each person and is made on their own settings page.
+ */
+export function registerOAuthClientMutationOptions(queryClient: QueryClient) {
+ return mutationOptions({
+ mutationFn: async (input: OAuthClientInput) => {
+ await client(
+ `/api/plugins/servers/${encodeURIComponent(input.serverId)}/oauth-client`,
+ {
+ method: "POST",
+ body: { clientId: input.clientId, clientSecret: input.clientSecret },
+ fallback: "That OAuth client could not be registered.",
+ },
+ );
+ },
+ onSuccess: () => invalidatePlugins(queryClient),
+ });
+}
+
+/**
+ * Begin connecting the signed-in person's own account.
+ *
+ * Answers with the vendor's consent URL rather than navigating, so the caller decides when to leave
+ * the page. There is deliberately nothing here that could complete the consent on somebody's behalf.
+ */
+/**
+ * Start a consent flow, and say which screen it started from.
+ *
+ * `returnTo` decides where the vendor's callback puts somebody down, because two screens offer this:
+ * a person's own connected-accounts page, and the connector's admin page where an administrator
+ * verifies the setup they have just finished. Sending an administrator to their personal settings
+ * afterwards is the round trip the inline row exists to remove.
+ *
+ * A name rather than a URL. The server narrows it to a known set before signing it into the state,
+ * so this parameter cannot become an open redirect however it is called.
+ */
+export function connectAccountMutationOptions(
+ returnTo: "settings" | "admin" = "settings",
+) {
+ return mutationOptions({
+ mutationFn: (serverId: string): Promise =>
+ client(
+ `/api/plugins/servers/${encodeURIComponent(serverId)}/connect?returnTo=${returnTo}`,
+ "authorizationUrl",
+ { method: "POST", fallback: "That account could not be connected." },
+ ),
+ });
+}
+
export function removeSkillMutationOptions(queryClient: QueryClient) {
return mutationOptions({
mutationFn: async (slug: string) => {
diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts
index 14d3a36a..525dd7a7 100644
--- a/app/src/lib/plugins/queries.ts
+++ b/app/src/lib/plugins/queries.ts
@@ -1,5 +1,5 @@
import { queryOptions } from "@tanstack/react-query";
-import { client, tryClient } from "@/lib/client";
+import { client } from "@/lib/client";
/** A tool one server offers, as the Plugins page sees it. */
export type PluginTool = {
@@ -49,7 +49,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;
};
@@ -58,6 +65,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. */
@@ -80,8 +95,40 @@ 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 client("/api/plugins/connections", {
+ fallback: "Your connected accounts could not be loaded.",
+ });
+ return response.json();
+ },
+ });
+}
+
export function pluginsPageQueryOptions() {
return queryOptions({
queryKey: pluginKeys.page(),
@@ -111,55 +158,3 @@ export function agentPluginsQueryOptions(agentId: string) {
},
});
}
-
-export type PluginCallOutcome =
- | { ok: true; text: string; isError: boolean }
- /** The deployment decided against it. `rule` names the expression, when one decided. */
- | { ok: false; refused: true; reason: string; rule: string | null }
- /** Remote server failure; distinct from a policy refusal. */
- | { ok: false; refused: false; reason: string };
-
-/**
- * Call a tool as a Bot, with server-side grant and policy rechecks for mid-run revocations.
- */
-export async function callPluginTool(
- ref: string,
- args: Record,
- agentId: string,
- signal?: AbortSignal,
-): Promise {
- /* A refused tool is an outcome this returns, not an error it throws. */
- const response = await tryClient("/api/plugins/call", {
- method: "POST",
- body: { ref, args, agentId },
- signal,
- });
-
- const body = (await response.json().catch(() => null)) as {
- text?: string;
- isError?: boolean;
- error?: string;
- rule?: string | null;
- } | null;
-
- if (response.ok) {
- return {
- ok: true,
- text: body?.text ?? "",
- isError: body?.isError === true,
- };
- }
- if (response.status === 403) {
- return {
- ok: false,
- refused: true,
- reason: body?.error ?? "That tool is not allowed here.",
- rule: body?.rule ?? null,
- };
- }
- return {
- ok: false,
- refused: false,
- reason: body?.error ?? "The server did not answer.",
- };
-}
diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts
index c7fae134..5cb09822 100644
--- a/app/src/routeTree.gen.ts
+++ b/app/src/routeTree.gen.ts
@@ -21,21 +21,24 @@ import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/ind
import { Route as AuthedAdminAuditRouteImport } from './routes/_authed/admin/audit'
import { Route as AuthedAdminBoundariesRouteImport } from './routes/_authed/admin/boundaries'
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 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 AuthedAdminConnectorsGoogleDriveRouteImport } from './routes/_authed/admin/connectors/google-drive'
+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'
+import { Route as AuthedSettingsConnectedAccountsIndexRouteImport } from './routes/_authed/settings/connected-accounts/index'
+import { Route as AuthedSettingsConnectedAccountsKeyRouteImport } from './routes/_authed/settings/connected-accounts/$key'
+import { Route as AuthedAdminPluginsKeyToolsToolRouteImport } from './routes/_authed/admin/plugins/$key_.tools.$tool'
const AuthedRoute = AuthedRouteImport.update({
id: '/_authed',
@@ -95,11 +98,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',
@@ -121,9 +119,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({
@@ -159,12 +157,16 @@ const AuthedAdminComponentsNameRoute =
path: '/components/$name',
getParentRoute: () => AuthedAdminRouteRoute,
} as any)
-const AuthedAdminConnectorsGoogleDriveRoute =
- AuthedAdminConnectorsGoogleDriveRouteImport.update({
- id: '/google-drive',
- path: '/google-drive',
- getParentRoute: () => AuthedAdminConnectorsRoute,
- } 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/',
@@ -177,6 +179,24 @@ const AuthedSettingsComponentsGalleryNameRoute =
path: '/components-gallery/$name',
getParentRoute: () => AuthedSettingsRouteRoute,
} as any)
+const AuthedSettingsConnectedAccountsIndexRoute =
+ AuthedSettingsConnectedAccountsIndexRouteImport.update({
+ id: '/connected-accounts/',
+ path: '/connected-accounts/',
+ getParentRoute: () => AuthedSettingsRouteRoute,
+ } as any)
+const AuthedSettingsConnectedAccountsKeyRoute =
+ AuthedSettingsConnectedAccountsKeyRouteImport.update({
+ id: '/connected-accounts/$key',
+ path: '/connected-accounts/$key',
+ getParentRoute: () => AuthedSettingsRouteRoute,
+ } as any)
+const AuthedAdminPluginsKeyToolsToolRoute =
+ AuthedAdminPluginsKeyToolsToolRouteImport.update({
+ id: '/plugins/$key_/tools/$tool',
+ path: '/plugins/$key/tools/$tool',
+ getParentRoute: () => AuthedAdminRouteRoute,
+ } as any)
export interface FileRoutesByFullPath {
'/': typeof AuthedAppIndexRoute
@@ -188,22 +208,25 @@ export interface FileRoutesByFullPath {
'/admin/audit': typeof AuthedAdminAuditRoute
'/admin/boundaries': typeof AuthedAdminBoundariesRoute
'/admin/computers': typeof AuthedAdminComputersRoute
- '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren
'/admin/credentials': typeof AuthedAdminCredentialsRoute
'/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/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute
+ '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute
'/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute
+ '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute
'/agents/': typeof AuthedAppAgentsIndexRoute
'/admin/components/': typeof AuthedAdminComponentsIndexRoute
+ '/admin/plugins/': typeof AuthedAdminPluginsIndexRoute
'/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute
+ '/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute
+ '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute
}
export interface FileRoutesByTo {
'/': typeof AuthedAppIndexRoute
@@ -213,22 +236,25 @@ export interface FileRoutesByTo {
'/admin/audit': typeof AuthedAdminAuditRoute
'/admin/boundaries': typeof AuthedAdminBoundariesRoute
'/admin/computers': typeof AuthedAdminComputersRoute
- '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren
'/admin/credentials': typeof AuthedAdminCredentialsRoute
'/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/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute
+ '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute
'/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute
+ '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute
'/agents': typeof AuthedAppAgentsIndexRoute
'/admin/components': typeof AuthedAdminComponentsIndexRoute
+ '/admin/plugins': typeof AuthedAdminPluginsIndexRoute
'/settings/components-gallery': typeof AuthedSettingsComponentsGalleryIndexRoute
+ '/settings/connected-accounts': typeof AuthedSettingsConnectedAccountsIndexRoute
+ '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -242,23 +268,26 @@ export interface FileRoutesById {
'/_authed/admin/audit': typeof AuthedAdminAuditRoute
'/_authed/admin/boundaries': typeof AuthedAdminBoundariesRoute
'/_authed/admin/computers': typeof AuthedAdminComputersRoute
- '/_authed/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren
'/_authed/admin/credentials': typeof AuthedAdminCredentialsRoute
'/_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/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute
+ '/_authed/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute
'/_authed/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute
+ '/_authed/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute
'/_authed/_app/agents/': typeof AuthedAppAgentsIndexRoute
'/_authed/admin/components/': typeof AuthedAdminComponentsIndexRoute
+ '/_authed/admin/plugins/': typeof AuthedAdminPluginsIndexRoute
'/_authed/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute
+ '/_authed/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute
+ '/_authed/admin/plugins/$key_/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -272,22 +301,25 @@ export interface FileRouteTypes {
| '/admin/audit'
| '/admin/boundaries'
| '/admin/computers'
- | '/admin/connectors'
| '/admin/credentials'
| '/admin/identity-providers'
| '/admin/people'
| '/admin/playground'
- | '/admin/plugins'
+ | '/admin/skills'
| '/admin/'
| '/settings/'
| '/channel/$channelId'
| '/channel/new'
| '/admin/components/$name'
- | '/admin/connectors/google-drive'
+ | '/admin/plugins/$key'
| '/settings/components-gallery/$name'
+ | '/settings/connected-accounts/$key'
| '/agents/'
| '/admin/components/'
+ | '/admin/plugins/'
| '/settings/components-gallery/'
+ | '/settings/connected-accounts/'
+ | '/admin/plugins/$key/tools/$tool'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -297,22 +329,25 @@ export interface FileRouteTypes {
| '/admin/audit'
| '/admin/boundaries'
| '/admin/computers'
- | '/admin/connectors'
| '/admin/credentials'
| '/admin/identity-providers'
| '/admin/people'
| '/admin/playground'
- | '/admin/plugins'
+ | '/admin/skills'
| '/admin'
| '/settings'
| '/channel/$channelId'
| '/channel/new'
| '/admin/components/$name'
- | '/admin/connectors/google-drive'
+ | '/admin/plugins/$key'
| '/settings/components-gallery/$name'
+ | '/settings/connected-accounts/$key'
| '/agents'
| '/admin/components'
+ | '/admin/plugins'
| '/settings/components-gallery'
+ | '/settings/connected-accounts'
+ | '/admin/plugins/$key/tools/$tool'
id:
| '__root__'
| '/_authed'
@@ -325,23 +360,26 @@ export interface FileRouteTypes {
| '/_authed/admin/audit'
| '/_authed/admin/boundaries'
| '/_authed/admin/computers'
- | '/_authed/admin/connectors'
| '/_authed/admin/credentials'
| '/_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/connectors/google-drive'
+ | '/_authed/admin/plugins/$key'
| '/_authed/settings/components-gallery/$name'
+ | '/_authed/settings/connected-accounts/$key'
| '/_authed/_app/agents/'
| '/_authed/admin/components/'
+ | '/_authed/admin/plugins/'
| '/_authed/settings/components-gallery/'
+ | '/_authed/settings/connected-accounts/'
+ | '/_authed/admin/plugins/$key_/tools/$tool'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -435,13 +473,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'
@@ -470,11 +501,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/': {
@@ -519,12 +550,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminComponentsNameRouteImport
parentRoute: typeof AuthedAdminRouteRoute
}
- '/_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
+ '/_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/'
@@ -540,50 +578,62 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedSettingsComponentsGalleryNameRouteImport
parentRoute: typeof AuthedSettingsRouteRoute
}
+ '/_authed/settings/connected-accounts/': {
+ id: '/_authed/settings/connected-accounts/'
+ path: '/connected-accounts'
+ fullPath: '/settings/connected-accounts/'
+ preLoaderRoute: typeof AuthedSettingsConnectedAccountsIndexRouteImport
+ parentRoute: typeof AuthedSettingsRouteRoute
+ }
+ '/_authed/settings/connected-accounts/$key': {
+ id: '/_authed/settings/connected-accounts/$key'
+ path: '/connected-accounts/$key'
+ fullPath: '/settings/connected-accounts/$key'
+ preLoaderRoute: typeof AuthedSettingsConnectedAccountsKeyRouteImport
+ parentRoute: typeof AuthedSettingsRouteRoute
+ }
+ '/_authed/admin/plugins/$key_/tools/$tool': {
+ id: '/_authed/admin/plugins/$key_/tools/$tool'
+ path: '/plugins/$key/tools/$tool'
+ fullPath: '/admin/plugins/$key/tools/$tool'
+ preLoaderRoute: typeof AuthedAdminPluginsKeyToolsToolRouteImport
+ parentRoute: typeof AuthedAdminRouteRoute
+ }
}
}
-interface AuthedAdminConnectorsRouteChildren {
- AuthedAdminConnectorsGoogleDriveRoute: typeof AuthedAdminConnectorsGoogleDriveRoute
-}
-
-const AuthedAdminConnectorsRouteChildren: AuthedAdminConnectorsRouteChildren = {
- AuthedAdminConnectorsGoogleDriveRoute: AuthedAdminConnectorsGoogleDriveRoute,
-}
-
-const AuthedAdminConnectorsRouteWithChildren =
- AuthedAdminConnectorsRoute._addFileChildren(
- AuthedAdminConnectorsRouteChildren,
- )
-
interface AuthedAdminRouteRouteChildren {
AuthedAdminAuditRoute: typeof AuthedAdminAuditRoute
AuthedAdminBoundariesRoute: typeof AuthedAdminBoundariesRoute
AuthedAdminComputersRoute: typeof AuthedAdminComputersRoute
- AuthedAdminConnectorsRoute: typeof AuthedAdminConnectorsRouteWithChildren
AuthedAdminCredentialsRoute: typeof AuthedAdminCredentialsRoute
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
+ AuthedAdminPluginsKeyToolsToolRoute: typeof AuthedAdminPluginsKeyToolsToolRoute
}
const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = {
AuthedAdminAuditRoute: AuthedAdminAuditRoute,
AuthedAdminBoundariesRoute: AuthedAdminBoundariesRoute,
AuthedAdminComputersRoute: AuthedAdminComputersRoute,
- AuthedAdminConnectorsRoute: AuthedAdminConnectorsRouteWithChildren,
AuthedAdminCredentialsRoute: AuthedAdminCredentialsRoute,
AuthedAdminIdentityProvidersRoute: AuthedAdminIdentityProvidersRoute,
AuthedAdminPeopleRoute: AuthedAdminPeopleRoute,
AuthedAdminPlaygroundRoute: AuthedAdminPlaygroundRoute,
- AuthedAdminPluginsRoute: AuthedAdminPluginsRoute,
+ AuthedAdminSkillsRoute: AuthedAdminSkillsRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminComponentsNameRoute: AuthedAdminComponentsNameRoute,
+ AuthedAdminPluginsKeyRoute: AuthedAdminPluginsKeyRoute,
AuthedAdminComponentsIndexRoute: AuthedAdminComponentsIndexRoute,
+ AuthedAdminPluginsIndexRoute: AuthedAdminPluginsIndexRoute,
+ AuthedAdminPluginsKeyToolsToolRoute: AuthedAdminPluginsKeyToolsToolRoute,
}
const AuthedAdminRouteRouteWithChildren =
@@ -592,15 +642,21 @@ const AuthedAdminRouteRouteWithChildren =
interface AuthedSettingsRouteRouteChildren {
AuthedSettingsIndexRoute: typeof AuthedSettingsIndexRoute
AuthedSettingsComponentsGalleryNameRoute: typeof AuthedSettingsComponentsGalleryNameRoute
+ AuthedSettingsConnectedAccountsKeyRoute: typeof AuthedSettingsConnectedAccountsKeyRoute
AuthedSettingsComponentsGalleryIndexRoute: typeof AuthedSettingsComponentsGalleryIndexRoute
+ AuthedSettingsConnectedAccountsIndexRoute: typeof AuthedSettingsConnectedAccountsIndexRoute
}
const AuthedSettingsRouteRouteChildren: AuthedSettingsRouteRouteChildren = {
AuthedSettingsIndexRoute: AuthedSettingsIndexRoute,
AuthedSettingsComponentsGalleryNameRoute:
AuthedSettingsComponentsGalleryNameRoute,
+ AuthedSettingsConnectedAccountsKeyRoute:
+ AuthedSettingsConnectedAccountsKeyRoute,
AuthedSettingsComponentsGalleryIndexRoute:
AuthedSettingsComponentsGalleryIndexRoute,
+ AuthedSettingsConnectedAccountsIndexRoute:
+ AuthedSettingsConnectedAccountsIndexRoute,
}
const AuthedSettingsRouteRouteWithChildren =
diff --git a/app/src/routes/_authed/admin/connectors.tsx b/app/src/routes/_authed/admin/connectors.tsx
deleted file mode 100644
index 53b0eadd..00000000
--- a/app/src/routes/_authed/admin/connectors.tsx
+++ /dev/null
@@ -1,87 +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 ? null : 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 4bd8dad6..00000000
--- a/app/src/routes/_authed/admin/connectors/google-drive.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { useForm } from "@tanstack/react-form";
-import { useMutation } from "@tanstack/react-query";
-import { createFileRoute } from "@tanstack/react-router";
-import { queryClient } from "@/query-client";
-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 { setUpGoogleDriveMutationOptions } from "@/lib/connectors/mutations";
-
-export const Route = createFileRoute("/_authed/admin/connectors/google-drive")({
- component: GoogleDriveConnectorPage,
-});
-
-function GoogleDriveConnectorPage() {
- const setup = useMutation(setUpGoogleDriveMutationOptions(queryClient));
- 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 6f974bc6..88839f17 100644
--- a/app/src/routes/_authed/admin/index.tsx
+++ b/app/src/routes/_authed/admin/index.tsx
@@ -3,10 +3,10 @@ import {
IconChevronRight,
IconCode,
IconDeviceDesktop,
+ IconFileText,
IconKey,
IconLayoutGrid,
IconListDetails,
- IconPlugConnected,
IconPuzzle,
IconShieldCheck,
IconUsers,
@@ -56,12 +56,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.",
@@ -88,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 20402f1b..00000000
--- a/app/src/routes/_authed/admin/plugins.tsx
+++ /dev/null
@@ -1,757 +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,
- 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 saveSkill = useMutation({
- ...saveSkillMutationOptions(queryClient),
- ...report,
- });
- const removeSkill = useMutation({
- ...removeSkillMutationOptions(queryClient),
- ...report,
- });
-
- /**
- * Adding a server is two writes: the token becomes a credential, then the record refers to it.
- * Chained here rather than in a factory because only this page knows a token was typed.
- */
- const addServerWithToken = async (
- run: (credentialId?: string) => void,
- serverId: string,
- token?: string,
- ) => {
- setError(null);
- try {
- run(await storeMcpToken(serverId, token));
- } catch (thrown) {
- setError((thrown as Error).message);
- }
- };
-
- const bots = (agents ?? []).map((agent: { id: string }) => ({
- id: agent.id,
- name: nameFor(agent.id),
- }));
-
- 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.
- */}
-
- 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.
-
- );
-}
diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx
new file mode 100644
index 00000000..9733d61c
--- /dev/null
+++ b/app/src/routes/_authed/admin/plugins/$key.tsx
@@ -0,0 +1,624 @@
+import {
+ IconArrowUpRight,
+ IconChevronRight,
+ IconExternalLink,
+} from "@tabler/icons-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { createFileRoute, Link, useParams } 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,
+ 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";
+import {
+ addCuratedServerMutationOptions,
+ connectAccountMutationOptions,
+ refreshPluginServerMutationOptions,
+ registerOAuthClientMutationOptions,
+ removePluginServerMutationOptions,
+} from "@/lib/plugins/mutations";
+import {
+ connectionsQueryOptions,
+ pluginsPageQueryOptions,
+} from "@/lib/plugins/queries";
+
+/**
+ * One vendor: what it needs from this deployment, and which Bots hold its tools.
+ *
+ * Its own page because what a connector needs configured differs by vendor and does not fit on a
+ * row. 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 screen this replaced tried to hold all of that in a
+ * list and grew a column per Bot, which is how a grant goes unread.
+ */
+export const Route = createFileRoute("/_authed/admin/plugins/$key")({
+ component: RouteComponent,
+});
+
+/** Which of the three shapes of edit a row opens, or none. */
+type OpenDialog = "token" | "client" | "instance" | null;
+
+/**
+ * How widely a tool is granted, in words rather than a fraction.
+ *
+ * "0/3" needs decoding and reads as a score. The two ends are the ones worth recognising without
+ * reading — nothing holds this, or everything does — so they are named, and the middle is the only
+ * case that gets a number.
+ */
+function grantSummary(held: number, total: number): string {
+ if (held === 0) return "No Bots";
+ if (held === total) return total === 1 ? "1 Bot" : "All Bots";
+ return `${held} of ${total} Bots`;
+}
+
+function RouteComponent() {
+ const { key } = useParams({ from: "/_authed/admin/plugins/$key" });
+ const queryClient = useQueryClient();
+ const plugins = useQuery(pluginsPageQueryOptions());
+ /*
+ * The administrator's OWN connections, not the deployment's.
+ *
+ * On an admin screen that is a deliberate mixture, and it is the useful one: setting a per-person
+ * connector up and finding out whether it works are two different questions, and the second has no
+ * answer anywhere on this page without it. Nobody else's connection is readable here — the endpoint
+ * only ever returns the caller's, so this cannot become a list of who has connected what.
+ */
+ const connections = useQuery(connectionsQueryOptions());
+ const { data: agents } = useQuery(agentListQueryOptions());
+ const youConnected = (connections.data?.connections ?? []).some(
+ (row) => row.serverId === key,
+ );
+ const nameFor = useBotNames();
+
+ const [error, setError] = useState(null);
+ const [dialog, setDialog] = useState(null);
+ const [token, setToken] = useState("");
+ const [instanceHost, setInstanceHost] = useState("");
+ const [client, setClient] = useState({ clientId: "", clientSecret: "" });
+
+ /* Every write reports into one banner rather than each growing its own handler. */
+ const report = { onError: (thrown: Error) => setError(thrown.message) };
+ const addCurated = useMutation({
+ ...addCuratedServerMutationOptions(queryClient),
+ ...report,
+ });
+ const registerClient = useMutation({
+ ...registerOAuthClientMutationOptions(queryClient),
+ ...report,
+ });
+ const refresh = useMutation({
+ ...refreshPluginServerMutationOptions(queryClient),
+ ...report,
+ });
+ const remove = useMutation({
+ ...removePluginServerMutationOptions(queryClient),
+ ...report,
+ });
+ const connectSelf = useMutation({
+ // Back to this page afterwards, not to the personal settings screen.
+ ...connectAccountMutationOptions("admin"),
+ ...report,
+ /*
+ * A full page navigation, not a fetch. The consent screen is the vendor's own and has to be
+ * shown to this person in their own browser; there is deliberately nothing here that could
+ * complete it for them, and nothing about being an administrator changes that.
+ */
+ onSuccess: (authorizationUrl) => {
+ window.location.href = authorizationUrl;
+ },
+ });
+ const entry = plugins.data?.catalogue.find((item) => item.key === key);
+ const server = plugins.data?.servers.find((item) => item.id === key);
+ const bots = (agents ?? []).map((agent: { id: string }) => ({
+ id: agent.id,
+ name: nameFor(agent.id),
+ }));
+
+ /**
+ * How this vendor is reached, from whichever record we have.
+ *
+ * A server added by URL has no catalogue entry, and nothing about it is reached as a person, so it
+ * falls back to the shared-token shape.
+ */
+ const auth = entry?.auth ?? "deployment-bearer";
+ const title = entry?.title ?? server?.title ?? key;
+
+ /** Adding is two writes when a token was typed: the credential, then the record pointing at it. */
+ const add = async () => {
+ setError(null);
+ try {
+ const credentialId =
+ auth === "deployment-bearer"
+ ? await storeMcpToken(key, token || undefined)
+ : undefined;
+ await addCurated.mutateAsync({
+ key,
+ instanceHost: instanceHost || undefined,
+ credentialId,
+ });
+ if (auth === "user-oauth" && client.clientId && client.clientSecret) {
+ await registerClient.mutateAsync({ serverId: key, ...client });
+ }
+ setToken("");
+ setClient({ clientId: "", clientSecret: "" });
+ setDialog(null);
+ } catch (thrown) {
+ setError((thrown as Error).message);
+ }
+ };
+
+ /* Nothing rather than a placeholder, so no sentence asserts anything while the fetch is open. */
+ if (plugins.isPending) {
+ return {null};
+ }
+ if (!(entry || server)) {
+ return (
+
+ Nothing to configure.
+
+ );
+ }
+
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {/*
+ * No section heading. This is one decision, and a heading over a single row that repeats the
+ * row's own title tells a reader nothing they cannot already see.
+ */}
+
+
+ {/*
+ * 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.
+ */}
+
+
+ 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);
+ }}
+ />
+
+
+
+
+
+ {server ? (
+
+ {/*
+ * Rows that DO something, and nothing else. The layout skill's third row kind — a value
+ * with no chevron and nothing to click — earns its place on a screen full of them, but
+ * among four actionable rows a dead one reads as a control that has stopped working. The
+ * redirect URI is prose under the card instead.
+ */}
+
+ {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}
+
+ {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"}
+
+
+
+
+ ) : null}
+
+ {/*
+ * The administrator's own account, on the setup screen.
+ *
+ * Setting a connector up and knowing whether it works are different questions, and the
+ * second used to have no answer here: an administrator finished configuring Drive and
+ * had to go to their personal settings to find out whether any of it was right. This row
+ * answers it in place, and stays honest about being personal — it is this person's
+ * connection, not deployment state, and it reaches their documents and nobody else's.
+ *
+ * It is NOT part of setup. The connector is fully configured without it, which is why it
+ * sits below the client and says so rather than reading as the next required step.
+ *
+ * Shown only once a client exists, because there is nothing to consent against before
+ * that: a Connect button with no OAuth client behind it can only fail.
+ */}
+ {auth === "user-oauth" && server?.hasCredential ? (
+ <>
+
+
+
+ Your account
+
+ {youConnected
+ ? "Connected, so a Bot granted these tools reads your Drive as you. Everybody else connects their own."
+ : "Connect your own account to try this connector. Setup is complete without it, and it reaches your documents only."}
+
+
+
+ {youConnected ? (
+ <>
+ {/* Decorative: the word beside it already says which. */}
+
+
+ Connected
+
+ >
+ ) : (
+ /* The arrow says this leaves OpenBot for the vendor's consent page. It does. */
+
+ )}
+
+
+ >
+ ) : 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}
+
+
+ {auth === "user-oauth" ? (
+
+
+ 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 ? (
+ /* Selectable and monospaced: it is copied by hand into somebody else's console. */
+
+ {plugins.data.redirectUri}
+
+ ) : (
+
+ This deployment has no public URL, so nobody can complete a
+ consent flow. Set OPENBOT_PUBLIC_URL.
+
+ )}
+
+ ) : null}
+
+ ) : null}
+
+ {server ? (
+ refresh.mutate(key)}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+ Refresh tools
+
+ }
+ description="A Bot is told about a tool only when it holds it. Every call is decided again when it happens, so removing a grant takes effect on the next one."
+ title="Tools"
+ >
+ {server.tools.length === 0 ? (
+
+ {server.lastError ??
+ "No tools listed. Refresh to ask the vendor again."}
+
+ ) : (
+
+ {server.tools.map((tool, index) => (
+
+ {/* A real link with no children: children passed to `render` replace the row's own. */}
+
+ }
+ size="sm"
+ >
+
+
+ {tool.name}
+
+ {tool.description}
+
+
+ {/*
+ * How many Bots hold it, not which. The names were here as a chip each and
+ * turned every row into a wrapping cluster of controls — twenty-four of them
+ * across this list — with the tool's own name losing the fight for attention.
+ * A count is what a reader scanning for "what is exposed, and how widely" is
+ * actually asking, and the names are one click away where they can be switched
+ * one at a time.
+ */}
+
+ {grantSummary(tool.grantedTo.length, bots.length)}
+
+ {/*
+ * 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}
+
+
+
+ );
+}
diff --git a/app/src/routes/_authed/admin/plugins/$key_.tools.$tool.tsx b/app/src/routes/_authed/admin/plugins/$key_.tools.$tool.tsx
new file mode 100644
index 00000000..d0149446
--- /dev/null
+++ b/app/src/routes/_authed/admin/plugins/$key_.tools.$tool.tsx
@@ -0,0 +1,203 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { createFileRoute, useParams } from "@tanstack/react-router";
+import { useState } from "react";
+import {
+ PageEmpty,
+ PageRows,
+ PageSection,
+ PageShell,
+} from "@/components/layout/page-shell";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ 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 { setPluginGrantMutationOptions } from "@/lib/plugins/mutations";
+import { pluginsPageQueryOptions } from "@/lib/plugins/queries";
+
+/**
+ * One tool, and which Bots hold it.
+ *
+ * Its own screen because a grant is a per-Bot decision and there is no upper bound on Bots. The
+ * connector page used to draw a chip for every Bot inside every tool row: at three Bots and eight
+ * tools that is twenty-four controls stacked in a list, wrapping onto second and third lines, where
+ * the thing being decided — does THIS Bot get THIS tool — was the least legible part of it. Here each
+ * Bot is one row with one switch, which is the same decision with nothing competing for it.
+ *
+ * `$key_` opts this route out of nesting under `$key.tsx`, so the connector page stays a page rather
+ * than becoming a layout with an outlet.
+ */
+export const Route = createFileRoute(
+ "/_authed/admin/plugins/$key_/tools/$tool",
+)({ component: RouteComponent });
+
+function RouteComponent() {
+ const { key, tool: toolName } = useParams({
+ from: "/_authed/admin/plugins/$key_/tools/$tool",
+ });
+ const queryClient = useQueryClient();
+ const plugins = useQuery(pluginsPageQueryOptions());
+ const { data: agents } = useQuery(agentListQueryOptions());
+ const nameFor = useBotNames();
+ const [error, setError] = useState(null);
+
+ const setGrant = useMutation({
+ ...setPluginGrantMutationOptions(queryClient),
+ onError: (thrown: Error) => setError(thrown.message),
+ });
+
+ const server = plugins.data?.servers.find((row) => row.id === key);
+ const tool = server?.tools.find((row) => row.name === toolName);
+
+ const back = {
+ label: server?.title ?? "Plugin",
+ linkProps: {
+ params: { key },
+ to: "/admin/plugins/$key" as const,
+ },
+ };
+
+ /* Nothing rather than a placeholder, so no sentence asserts anything while the fetch is open. */
+ if (plugins.isPending) {
+ return {null};
+ }
+
+ if (!tool) {
+ return (
+
+ {/*
+ * Says which of the two it is. A tool disappears from this list when the vendor stops
+ * offering it, and that reads very differently from a mistyped address.
+ */}
+
+ {server
+ ? "It may have been withdrawn since the tool list was last refreshed."
+ : "This deployment has not enabled that connector."}
+
+
+ );
+ }
+
+ const bots = (agents ?? []).map((agent: { id: string }) => ({
+ id: agent.id,
+ name: nameFor(agent.id),
+ }));
+
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ {/*
+ * Read-only, and the layout skill's third row kind is right here: there is one of it, it is
+ * the fact the section exists to state, and nothing about it is switchable. The effect
+ * comes from the vendor's own classification, not from the tool's name.
+ */}
+
+
+ Effect
+
+ Decided by the connector, not by the tool's name. Anything
+ unrecognised counts as a write.
+
+
+
+
+ {tool.effect === "write" ? "changes things" : "reads"}
+
+
+
+
+
+
+
+ {bots.length === 0 ? (
+
+ This deployment has no Bots yet, so there is nobody to grant this
+ to.
+
+ ) : (
+
+ {bots.map((bot, index) => {
+ const held = tool.grantedTo.includes(bot.id);
+ return (
+
+
+
+ {bot.name}
+
+ {held
+ ? "May call this tool. Every call is still checked against the boundaries and written to the audit trail."
+ : "Cannot call this tool. It is not offered to the model at all, so it has nothing to refuse."}
+
+
+
+ {/*
+ * Binary and immediate, which is what a Switch is for: it takes effect when
+ * switched and there is no save. Disabled only while its own write is in
+ * flight, so switching one Bot does not freeze the rest of the list.
+ */}
+ {
+ setError(null);
+ setGrant.mutate({
+ agentId: bot.id,
+ granted: next,
+ kind: "mcp",
+ ref: tool.ref,
+ });
+ }}
+ />
+
+
+ {index !== bots.length - 1 && }
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
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..69b6ee44
--- /dev/null
+++ b/app/src/routes/_authed/admin/plugins/index.tsx
@@ -0,0 +1,236 @@
+import {
+ IconBrandGoogleDrive,
+ 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 { RowMark } from "@/components/layout/row-mark";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ 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.
+ *
+ * The fallback covers a server an administrator added by URL, which has no catalogue entry and so no
+ * mark of its own — and it would cover a catalogue vendor Tabler ships no brand for. Only Drive is in
+ * the catalogue today, and Tabler has it.
+ */
+const MARKS: Record> = {
+ "google-drive": IconBrandGoogleDrive,
+};
+
+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 && }
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx
new file mode 100644
index 00000000..c59dfcdf
--- /dev/null
+++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx
@@ -0,0 +1,231 @@
+import { IconArrowUpRight, IconChevronDown } from "@tabler/icons-react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { createFileRoute, useParams } from "@tanstack/react-router";
+import { useState } from "react";
+import {
+ PageEmpty,
+ PageRows,
+ PageSection,
+ PageShell,
+} from "@/components/layout/page-shell";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemTitle,
+} from "@/components/ui/item";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Separator } from "@/components/ui/separator";
+import { connectAccountMutationOptions } from "@/lib/plugins/mutations";
+import {
+ connectionsQueryOptions,
+ pluginsPageQueryOptions,
+} from "@/lib/plugins/queries";
+
+/**
+ * One service, and whether a Bot may read it as you.
+ *
+ * Its own page rather than a switch on the list, because what a connector needs from a person is not
+ * fixed. Drive needs one consent and nothing else; a vendor that scopes access per workspace, or per
+ * folder, or asks which of several accounts to use, needs somewhere to ask. This is that somewhere,
+ * before there is anything to put in it.
+ */
+export const Route = createFileRoute(
+ "/_authed/settings/connected-accounts/$key",
+)({ component: RouteComponent });
+
+function RouteComponent() {
+ const { key } = useParams({
+ from: "/_authed/settings/connected-accounts/$key",
+ });
+ const plugins = useQuery(pluginsPageQueryOptions());
+ const connections = useQuery(connectionsQueryOptions());
+ const [notice, setNotice] = useState(null);
+
+ const connect = useMutation({
+ ...connectAccountMutationOptions(),
+ onError: (thrown: Error) => setNotice(thrown.message),
+ /*
+ * A full page navigation, not a fetch. The consent screen is the vendor's own and has to be shown
+ * to you in your own browser; there is deliberately nothing here that could complete it for you.
+ */
+ onSuccess: (authorizationUrl) => {
+ window.location.href = authorizationUrl;
+ },
+ });
+
+ const entry = plugins.data?.catalogue.find((item) => item.key === key);
+ const enabled = (plugins.data?.servers ?? []).some((s) => s.id === key);
+ const connection = (connections.data?.connections ?? []).find(
+ (row) => row.serverId === key,
+ );
+
+ if (plugins.isPending) {
+ return {null};
+ }
+
+ const back = {
+ label: "Connected accounts",
+ linkProps: { to: "/settings/connected-accounts" as const },
+ };
+
+ /*
+ * A vendor that is not reached as a person has nothing here for anybody to decide, and one an
+ * administrator has not enabled cannot be consented to — there is no OAuth client behind it. Both
+ * say which it is rather than drawing a switch that cannot work.
+ */
+ if (entry?.auth !== "user-oauth") {
+ return (
+
+
+ {entry
+ ? "A Bot reaches this one with a credential the deployment holds, the same for everybody."
+ : "This deployment has no connector by that name."}
+
+
+ );
+ }
+
+ return (
+
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+
+ {/* One decision, so no heading: it would only repeat the row's own title. */}
+
+
+
+
+ {/* Not "Connect your account": the row is also the connected state, and a title has to
+ read for both. */}
+ Your account
+
+ {!enabled
+ ? "An administrator has not enabled this connector, so there is nothing to connect to yet."
+ : connection
+ ? "A Bot granted its tools reads this as you, and sees only what you can see."
+ : "No Bot can read this as you. Connecting takes you to the vendor to consent."}
+
+
+
+ {connection ? (
+ /*
+ * A state and a menu, not a switch. Connected is a fact about a grant that lives at
+ * the vendor, and withdrawing it is a deliberate act rather than the other half of a
+ * position — so it is named in a menu instead of being whatever happens when
+ * something slides back.
+ */
+
+
+
+ Connected
+
+
+ }
+ />
+ {/*
+ * `w-auto`, because the default is `w-(--anchor-width)` — the width of the trigger,
+ * which here is a small "Connected" button. Left alone, the one item inside wraps
+ * onto three lines and a destructive action becomes hard to read at the moment it
+ * most needs to be legible.
+ */}
+
+
+ /*
+ * NOT BUILT YET, and it says so rather than appearing to work.
+ *
+ * Withdrawing is three acts — revoke at the vendor, revoke the vault
+ * credential, delete the row — and none exist. An item that closed the menu
+ * and changed nothing would report that access had been withdrawn when it
+ * had not, which is the one outcome worse than not offering it.
+ */
+ setNotice(
+ `Disconnecting is not built yet. Until it is, revoke it in your ${entry.vendor} account's third-party access settings — that stops this deployment reading anything immediately.`,
+ )
+ }
+ className="whitespace-nowrap"
+ variant="destructive"
+ >
+ Disconnect your {entry.title} account
+
+
+
+ ) : (
+ /*
+ * The arrow says this leaves OpenBot. It does: the next thing on screen is the
+ * vendor's own consent page, and a control that navigates away should look like one.
+ */
+
+ )}
+
+
+
+
+
+ {connection ? (
+
+
+
+
+ Granted
+
+ {connection.scope || "The vendor named no scope."}
+
+
+
+
+
+
+ Connected
+
+
+
+ {new Date(connection.connectedAt).toLocaleString()}
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx
new file mode 100644
index 00000000..07e2bf6a
--- /dev/null
+++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx
@@ -0,0 +1,171 @@
+import {
+ IconBrandGoogleDrive,
+ 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 { RowMark } from "@/components/layout/row-mark";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemTitle,
+} from "@/components/ui/item";
+import { Separator } from "@/components/ui/separator";
+import {
+ connectionsQueryOptions,
+ pluginsPageQueryOptions,
+} from "@/lib/plugins/queries";
+import { cn } from "@/lib/utils";
+
+/**
+ * The services a Bot reads as you.
+ *
+ * Yours, not the deployment's. An administrator decides which vendors this deployment may reach at
+ * all; this is the other half of that decision, and it is one nobody can make for you — there is no
+ * endpoint for an administrator to connect an account on somebody's behalf. A Bot calling one of
+ * these runs on your own grant, so it sees exactly what you can see and nothing else.
+ */
+export const Route = createFileRoute("/_authed/settings/connected-accounts/")({
+ component: RouteComponent,
+ /*
+ * `?connected=` is how the OAuth callback reports back, carrying a server key on success and
+ * `failed` otherwise. It is the only channel available: the callback is a redirect from another
+ * company's server, so there is no response body to read.
+ *
+ * The key is omitted rather than set to undefined. Present-but-undefined makes `search` a required
+ * prop on every Link to this route, which is a lot of ripple for a parameter only the callback sets.
+ */
+ validateSearch: (search: Record): { connected?: string } =>
+ typeof search.connected === "string" ? { connected: search.connected } : {},
+});
+
+/** The same marks the admin connector list uses: these are the same vendors seen from your side. */
+const MARKS: Record> = {
+ "google-drive": IconBrandGoogleDrive,
+};
+
+const markFor = (key: string) => MARKS[key] ?? IconPlug;
+
+function RouteComponent() {
+ const { connected: outcome } = Route.useSearch();
+ 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));
+
+ /*
+ * Only vendors reached as a person, and only ones an administrator has enabled.
+ *
+ * A vendor with a shared token has nothing for you to decide: it answers the same for everybody,
+ * so listing it here would offer a choice you do not have. And a vendor nobody has enabled cannot
+ * be connected at all, because there is no OAuth client to consent against.
+ */
+ const yours = (plugins.data?.catalogue ?? []).filter(
+ (entry) => entry.auth === "user-oauth" && added.has(entry.key),
+ );
+
+ return (
+
+ {/*
+ * Only the failure is worth saying. A success needs no sentence: the row it came back to now
+ * reads "Connected", which is the same news told by the thing it is news about.
+ */}
+ {outcome === "failed" ? (
+
+ That account could not be connected. Nothing was saved — try again.
+
+ ) : (
+
+ {yours.length === 0 ? (
+ /*
+ * Says whose move it is. "Nothing here" on its own reads as though you failed to do
+ * something, when what is missing is an administrator enabling a connector.
+ */
+
+ Nothing to connect yet. These appear once an administrator enables
+ a connector that reads as the person asking.
+
+ ) : (
+
+ {yours.map((entry, index) => {
+ const Mark = markFor(entry.key);
+ return (
+
+ {/* A real link with no children: children passed to `render` replace the row's own. */}
+
+ }
+ size="sm"
+ >
+
+
+
+
+ {entry.title}
+ {entry.summary}
+
+
+ {/*
+ * A dot, so connected is legible without reading. Two states that differ only
+ * by the word "not" are two states somebody has to read carefully to tell
+ * apart, which is the wrong amount of effort for the only fact this row
+ * carries. The same green as the account page's own control, so the list and
+ * the page it opens agree at a glance.
+ *
+ * Decorative: the text beside it already says which, so a screen reader that
+ * announced the dot as well would say it twice.
+ */}
+
+
+ {connected.has(entry.key)
+ ? "Connected"
+ : "Not connected"}
+
+
+
+
+ {index !== yours.length - 1 && }
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx
index cf2c8597..9b4991da 100644
--- a/app/src/routes/_authed/settings/index.tsx
+++ b/app/src/routes/_authed/settings/index.tsx
@@ -25,6 +25,9 @@ function RouteComponent() {
* The measurements that used to be written out here now live in `PageShell`, which Skills, Admin
* and this screen all render through. The reason they match is no longer that somebody remembered
* to copy them.
+ *
+ * Connected accounts used to be a section below. It is its own screen now: a connector can need
+ * more from a person than one switch, and a section cannot grow a page's worth of that.
*/
return (
{
- expect(connectorKeys.list()).toEqual(["connectors", "list"]);
-});
diff --git a/docs/README.md b/docs/README.md
index cdbc6419..7392c968 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,6 +6,8 @@ Start with the root [README](../README.md), then use these references:
- [Configuration](configuration.md): environment variables and tenant package YAML.
- [Development](development.md): local setup, migrations, ports, and quality checks.
- [Coworkers](coworkers.md): durable Bot profiles, channels, visibility, deletion, and external AG-UI registration.
+- Plugins, one connector per page — what an administrator registers, what each person consents to, and what the failures mean:
+ - [Google Drive](plugins/google-drive.md)
- [Deployment](deployment.md): the container, what is in the image, minimum sizes, and the platform notes.
- [Releasing](releasing.md): how a release is proposed, reviewed and published.
diff --git a/docs/architecture.md b/docs/architecture.md
index 2709a62d..041f71ff 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -136,7 +136,9 @@ MCP servers and skills share the plugin grant table, but they have different own
- MCP tools are admin-governed because they can reach external systems with stored credentials.
- Skills are reusable instructions. A person can create personal skills and attach them only to Bots they own. Administrators create deployment skills.
-The curated MCP catalogue contains Atlassian, Box, Slack, Salesforce, and ServiceNow. Custom MCP servers must pass URL checks; unknown tools and custom-server tools are treated as writes unless positively classified as reads.
+The curated MCP catalogue contains Google Drive. Custom MCP servers must pass URL checks; unknown tools and custom-server tools are treated as writes unless positively classified as reads.
+
+A catalogue entry says whose credential a Bot reaches it with, which is a different question from whether it is reachable at all. A deployment-wide token answers the same for everybody; Google Drive is `user-oauth`, so a Bot reads it as the person asking and sees only what that person can see. An administrator enabling the connector and a person connecting their own account are two decisions, and neither can be made for the other. See [Google Drive](plugins/google-drive.md).
Every MCP call checks the grant first, then evaluates the same action policy engine with MCP context, then audits the result.
diff --git a/docs/configuration.md b/docs/configuration.md
index 7a3ea8d8..163661d8 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -114,6 +114,8 @@ Two things are worth knowing before pointing a deployment at any gateway. Not ev
| `BETTER_AUTH_URL` | Public API server base URL, where OAuth callbacks return. Required with any provider. |
| `TRUSTED_ORIGINS` | Comma-separated app origins accepted by the API, plus every host in a registered OIDC provider's discovery document. |
| `INITIAL_ADMIN_EMAILS` | Comma-separated administrators. **Required** with any provider. |
+| `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. |
**With no provider at all, `OPENBOT_SINGLE_USER=true` is required.** A deployment that configures
nothing to sign anybody in and does not say that was deliberate refuses to start, naming what to
@@ -148,6 +150,12 @@ added it. The client secret and any SAML signing material are encrypted at rest
The redirect URI to register with each provider is `/api/auth/callback/`,
where `` is `google`, `microsoft` or `okta`.
+`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
| Variable | Meaning |
@@ -277,7 +285,11 @@ agents:
role_description: Answer company knowledge questions and cite sources.
avatar_seed: knowledge
type: built-in
- system_prompt: Answer from authorized company knowledge and cite your sources. When none is connected, say so plainly rather than inventing a citation.
+ system_prompt: >-
+ Answer from the sources you can reach with the tools you have been given, and cite what you
+ used. If you have no tool for a source, or a tool tells you it is not connected or reports an
+ error, say that plainly. Never answer from your own memory as though it came from a source, and
+ never claim you lack access to something a tool has just returned.
- id: risk-analyst
name: Risk Analyst
diff --git a/docs/plugins/google-drive.md b/docs/plugins/google-drive.md
new file mode 100644
index 00000000..769bf9a1
--- /dev/null
+++ b/docs/plugins/google-drive.md
@@ -0,0 +1,209 @@
+# Google Drive
+
+A Bot with this connector granted reads Drive **as the person asking**. Two people asking the same
+question get the answers their own accounts can see, and neither sees anything they could not open
+themselves. Read-only: the scope requested is `drive.readonly`, so Google refuses a write before
+this deployment has to.
+
+Setting it up takes two people, and neither can do the other's half:
+
+| Who | Does | Where |
+| ----------------- | ------------------------------------------------------- | ---------------------------------------- |
+| An administrator | Registers the OAuth client and enables the connector | Google Cloud console, then `/admin/plugins/google-drive` |
+| Each person | Consents with their own Google account | `/settings/connected-accounts/google-drive` |
+
+There is deliberately no endpoint for an administrator to connect an account on somebody's behalf.
+
+## What an administrator does
+
+### 0. Enrol the project in the Developer Preview Program
+
+The Workspace MCP servers are, in Google's words, "available as part of the Google Workspace
+Developer Preview Program". A project that is not enrolled can have every other step below correct —
+APIs enabled, client registered, a token Google's own `tokeninfo` endpoint validates — and still be
+refused, because this gate is checked against the project rather than against the credential.
+
+It refuses with `The caller does not have permission`, which says nothing about enrolment. Do this
+first; it is the step with the least evidence that it is missing.
+
+### 1. Enable both APIs
+
+In a Google Cloud project, enable **both** of these:
+
+- `drive.googleapis.com` — the Drive API
+- `drivemcp.googleapis.com` — the Drive **MCP** API
+
+Enabling the first does not enable the second. They are separate APIs, and the connector talks to the
+second one. This is the single most likely reason a correctly-configured connector still returns
+`403`; see [Troubleshooting](#troubleshooting).
+
+Each other Workspace product is its own pair — Gmail is `gmail.googleapis.com` and
+`gmailmcp.googleapis.com` — so the same step returns for every connector added later.
+
+### 2. Configure the OAuth consent screen
+
+- Scope: `https://www.googleapis.com/auth/drive.readonly`.
+- While the app is in **Testing**, only accounts listed as test users can consent. Everyone who will
+ connect needs to be on that list, or their consent fails with an access-denied error that says
+ nothing about test users.
+
+### 3. Create an OAuth client
+
+Type **Web application**. Under **Authorised redirect URIs**, add this deployment's callback:
+
+```
+/api/plugins/oauth/callback
+```
+
+Locally that is `http://localhost:3001/api/plugins/oauth/callback` — port 3001, the API, not 3010,
+the app. The callback lands on the API and redirects back to the app afterwards.
+
+It has to match character for character: scheme, host, port, path, no trailing slash. OpenBot shows
+the exact string to paste under the **Connection** section of the plugin page, built from
+`OPENBOT_PUBLIC_URL` rather than from the incoming request — a redirect URI assembled from a request
+header is one an attacker has a say in. Copy it from there rather than typing it.
+
+Keep the client ID and client secret for the next step.
+
+### 4. Enable the connector in OpenBot
+
+At `/admin/plugins/google-drive`:
+
+1. Turn on **Enable for this deployment**.
+2. Open **OAuth client** and paste the client ID and secret. The secret is encrypted with
+ `KEY_ENCRYPTION_KEY` and never read back out to the browser.
+3. Press **Refresh tools**, which records the four read tools this connector implements.
+
+That completes setup. No personal account is needed to get this far — the tool list for this
+connector is OpenBot's own code rather than an answer from a remote server, so there is nothing to
+authenticate in order to read it.
+
+To check it actually works, use **Your account** on the same page: it connects *your* Google account
+and returns you here. That is a personal grant like anybody else's, reaching your documents only, and
+it is not part of configuring the connector — a deployment is correctly set up whether or not the
+administrator ever connects.
+
+### 5. Grant tools to a Bot
+
+Enabling the connector does not give any Bot access to it. Each tool is granted per Bot, the same as
+every other plugin tool. Every call then checks the grant, evaluates the action policy, and writes an
+audit row.
+
+## What each person does
+
+At `/settings/connected-accounts`, Google Drive appears once an administrator has enabled it. Open it
+and press **Connect**. That leaves OpenBot for Google's own consent screen — the arrow on the button
+says so — and returns to the same page, which then reads **Connected** with the scope Google actually
+granted.
+
+Nothing is cached. OpenBot stores the refresh token and mints a short-lived access token for each
+call, so revoking access at Google takes effect on the next call rather than whenever a cache
+expires.
+
+### Disconnecting
+
+**Not built yet.** Until it is, revoke it in Google's own third-party access settings
+([myaccount.google.com/connections](https://myaccount.google.com/connections)), which stops this
+deployment reading anything immediately. The page says the same thing rather than offering a control
+that would report access withdrawn when it had not been.
+
+## Troubleshooting
+
+Every message below is what OpenBot actually shows. They are worth reading literally: the connector
+distinguishes "the credential was refused" from "the credential was accepted and the request was
+refused", and those have completely different fixes.
+
+**Look at the audit trail first.** Every call leaves one row, written after the attempt, and the
+event type is the answer to "whose problem is this":
+
+| Row | Means |
+| -------------------- | ---------------------------------------------------------------------- |
+| `mcp.call_rejected` | This deployment declined. A missing grant, or a policy rule — `decision.rule` names which. |
+| `mcp.call_failed` | Permitted here, failed at the vendor. `failure` carries the vendor's own sentence. |
+| `mcp.call_succeeded` | The vendor answered. |
+
+A Bot that appears to have no access and leaves **no rows at all** never called the tool, which is a
+grant problem rather than a connection problem: check that the tool is granted to *that* Bot at
+`/admin/plugins/google-drive`. Enabling the connector and connecting your account both being done
+still leaves each tool ungranted.
+
+### `redirect_uri_mismatch` on the consent screen
+
+Google is comparing the `redirect_uri` OpenBot sent against the list on the OAuth client, as exact
+strings. Compare the value shown under **Connection** on the plugin page with what is registered,
+character for character. Common mismatches: `127.0.0.1` against `localhost`, the app's port instead
+of the API's, `https` against `http`, a trailing slash.
+
+The client the error is about is the one whose ID is in the URL. A deployment with more than one
+Google client can have the URI registered on the wrong one.
+
+### "The vendor rejected this credential (401)"
+
+Google will not accept the token at all. Reconnecting the account is the usual fix. If it persists,
+the scopes granted do not cover this server — check what the **Access** section reports as granted
+against what the connector asks for.
+
+Worth knowing when reading raw logs: Google's MCP servers answer an unauthenticated `tools/list`
+with **401 and a complete, valid tool list in the body**. A wall of successful-looking JSON in an
+error is a refusal, not a parsing bug.
+
+### "The vendor accepted the credential and refused the request (403). It said: …"
+
+The credential is fine. Read the sentence after "It said:" — it is Google's own, and for the most
+common cause it names the API and includes the console URL to enable it. That cause is
+[step 1](#1-enable-both-apis): `drivemcp.googleapis.com` is not enabled, even where
+`drive.googleapis.com` is.
+
+Enabling an API takes a few minutes to propagate. Wait, then press **Refresh tools** again.
+
+If the sentence is about access rather than an API, the account genuinely cannot see what was asked
+for, which is the connector working as intended.
+
+### "The caller does not have permission"
+
+Google's `PERMISSION_DENIED`, arriving as an `isError` result rather than an HTTP status. It is a
+statement about the **project**, not about the credential, which is what makes it so misleading: the
+token is fine, and every check that a person can run says so.
+
+Worth knowing how thoroughly fine, because it saves repeating the work. Minting an access token from
+the stored refresh token and asking `https://oauth2.googleapis.com/tokeninfo` about it returns 200
+with the right `aud`, the right `azp` and the granted scope — Google validating its own token. The
+refusal is downstream of everything OpenBot controls.
+
+In order of likelihood:
+
+1. **The project is not enrolled** in the Developer Preview Program ([step 0](#0-enrol-the-project-in-the-developer-preview-program)).
+2. **A scope is missing.** Google's guide lists Drive as needing `drive.readonly` *and*
+ `drive.file`, added together. OpenBot requests only `drive.readonly`, deliberately: `drive.file`
+ is write-capable, and the connector's read-only guarantee is currently the scope itself rather
+ than only the tool classification. Widening it is a decision about what this deployment may do to
+ somebody's Drive, so it is not done pre-emptively.
+
+A missing scope usually reads as "insufficient authentication scopes" instead, which is why
+enrolment is the first thing to check rather than the second.
+
+### "You have not connected your Google Drive account."
+
+The Bot was asked to read as somebody with no connection stored. Connect at
+`/settings/connected-accounts/google-drive`. There is no fallback to a deployment-wide credential —
+by design, since a fallback would answer with somebody else's access.
+
+### The connection worked and stopped about an hour later
+
+That is an access token with no refresh token behind it, which OpenBot refuses to store precisely so
+this cannot happen; if you see it, say so, because it means something got past that check. Google
+returns no refresh token when it believes the person already consented, which is why the
+authorization URL sends both `access_type=offline` and `prompt=consent`.
+
+### "The tool returned no content. Nothing was found, so there is nothing here to answer from."
+
+Not an error. The tool ran and matched nothing, and this sentence exists so a model is told that
+rather than handed an empty string it would fill in from memory.
+
+## See also
+
+- [Architecture](../architecture.md) — where plugins, grants, policy and audit sit.
+- [Configuration](../configuration.md) — `OPENBOT_PUBLIC_URL`, `OPENBOT_APP_URL`,
+ `KEY_ENCRYPTION_KEY`.
+- [Google's own guide](https://developers.google.com/workspace/guides/configure-mcp-servers) to
+ configuring Workspace MCP servers.
diff --git a/examples/fintech/agents.yaml b/examples/fintech/agents.yaml
index a41507e8..4d58dfcd 100644
--- a/examples/fintech/agents.yaml
+++ b/examples/fintech/agents.yaml
@@ -14,7 +14,23 @@ agents:
role_description: Help answer company knowledge questions and cite sources when available.
avatar_seed: knowledge
type: built-in
- system_prompt: Answer from authorized company knowledge and cite your sources. When none is connected, say so plainly rather than inventing a citation.
+ # The tools decide what is connected, not this prompt.
+ #
+ # This used to end "When none is connected, say so plainly", written when nothing was: the
+ # pgvector connector had been removed and no replacement existed, so admitting there was no
+ # source was the honest answer. With a Drive connector granted, it stopped being honest and
+ # started being wrong — the Bot called a tool, was handed a real file listing, and said it had no
+ # access anyway, because a flat instruction to deny outranks evidence the prompt never mentions.
+ #
+ # So the condition is now something observable. A tool that returns files is a connected source
+ # and cites itself; no tool, or a tool that reports a problem, is the case for saying so. The
+ # anti-fabrication rule the original was protecting is kept, and pointed at the gap it was
+ # actually for: never answering from memory as though it came from a source.
+ system_prompt: >-
+ Answer from the sources you can reach with the tools you have been given, and cite what you
+ used. If you have no tool for a source, or a tool tells you it is not connected or reports an
+ error, say that plainly. Never answer from your own memory as though it came from a source, and
+ never claim you lack access to something a tool has just returned.
# The Bot that ships in the box (agent-bot), addressed exactly the way a customer's own Bot would
# be: an AG-UI endpoint in the registry. Names in dollar-brace form are read from the environment,
# so the address belongs to the deployment. Replace it with your own service and nothing changes.
diff --git a/server/drizzle/0008_whole_retro_girl.sql b/server/drizzle/0008_whole_retro_girl.sql
new file mode 100644
index 00000000..38c18f69
--- /dev/null
+++ b/server/drizzle/0008_whole_retro_girl.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/0009_lumpy_sunfire.sql b/server/drizzle/0009_lumpy_sunfire.sql
new file mode 100644
index 00000000..02377e74
--- /dev/null
+++ b/server/drizzle/0009_lumpy_sunfire.sql
@@ -0,0 +1,25 @@
+-- A pointer to a credential that does not exist is cleared before the key is added.
+--
+-- Hand-written, because a generator cannot know this. `credential_id` was `text` against a `uuid`
+-- primary key with no foreign key, so a deployment is allowed to be holding a pointer to a row that
+-- was deleted underneath it — which is the bug this migration exists to make impossible, and which
+-- has happened. Adding the constraint on top of one of those rows fails, so the deployment that most
+-- needs this migration would be the one that could not run it.
+--
+-- Cleared rather than repaired: there is nothing to repair to. A pointer to nothing is not a
+-- credential, and null is what this column already means by "this server has none" — so the
+-- connector correctly reports that no credential is registered, instead of looking configured and
+-- failing at the vendor. An administrator registers it again, which is the only way back anyway.
+--
+-- Anything that is not a uuid at all is cleared too. The old type permitted it, so no deployment can
+-- promise otherwise, and the cast below would fail on it.
+UPDATE "mcp_servers" SET "credential_id" = NULL
+WHERE "credential_id" IS NOT NULL
+ AND (
+ "credential_id" !~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
+ OR NOT EXISTS (
+ SELECT 1 FROM "credentials" WHERE "credentials"."id" = "mcp_servers"."credential_id"::uuid
+ )
+ );--> statement-breakpoint
+ALTER TABLE "mcp_servers" ALTER COLUMN "credential_id" SET DATA TYPE uuid USING "credential_id"::uuid;--> statement-breakpoint
+ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_credential_id_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."credentials"("id") ON DELETE restrict ON UPDATE no action;
diff --git a/server/drizzle/meta/0008_snapshot.json b/server/drizzle/meta/0008_snapshot.json
new file mode 100644
index 00000000..19debb25
--- /dev/null
+++ b/server/drizzle/meta/0008_snapshot.json
@@ -0,0 +1,2868 @@
+{
+ "id": "cefd42ab-8f3b-48c9-9af4-a024a6c1b8f9",
+ "prevId": "51e27ca5-9eb6-4fb3-9098-760f208403fc",
+ "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
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {}
+ },
+ "audit_events_type_time_idx": {
+ "name": "audit_events_type_time_idx",
+ "columns": [
+ {
+ "expression": "event_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_actor_time_idx": {
+ "name": "audit_events_actor_time_idx",
+ "columns": [
+ {
+ "expression": "actor_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_target_time_idx": {
+ "name": "audit_events_target_time_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "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.revoked_access": {
+ "name": "revoked_access",
+ "schema": "",
+ "columns": {
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_by": {
+ "name": "revoked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "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.sso_providers": {
+ "name": "sso_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_config": {
+ "name": "oidc_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "saml_config": {
+ "name": "saml_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sso_providers_user_id_users_id_fk": {
+ "name": "sso_providers_user_id_users_id_fk",
+ "tableFrom": "sso_providers",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sso_providers_provider_id_unique": {
+ "name": "sso_providers_provider_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["provider_id"]
+ }
+ },
+ "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.computer_snapshot": {
+ "name": "computer_snapshot",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "elements": {
+ "name": "elements",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "taken_at": {
+ "name": "taken_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/0009_snapshot.json b/server/drizzle/meta/0009_snapshot.json
new file mode 100644
index 00000000..3fa98506
--- /dev/null
+++ b/server/drizzle/meta/0009_snapshot.json
@@ -0,0 +1,2878 @@
+{
+ "id": "60ef889e-623b-483b-bac6-16ca31959ff5",
+ "prevId": "cefd42ab-8f3b-48c9-9af4-a024a6c1b8f9",
+ "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
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {}
+ },
+ "audit_events_type_time_idx": {
+ "name": "audit_events_type_time_idx",
+ "columns": [
+ {
+ "expression": "event_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_actor_time_idx": {
+ "name": "audit_events_actor_time_idx",
+ "columns": [
+ {
+ "expression": "actor_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_target_time_idx": {
+ "name": "audit_events_target_time_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "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.revoked_access": {
+ "name": "revoked_access",
+ "schema": "",
+ "columns": {
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_by": {
+ "name": "revoked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "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.sso_providers": {
+ "name": "sso_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_config": {
+ "name": "oidc_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "saml_config": {
+ "name": "saml_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sso_providers_user_id_users_id_fk": {
+ "name": "sso_providers_user_id_users_id_fk",
+ "tableFrom": "sso_providers",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sso_providers_provider_id_unique": {
+ "name": "sso_providers_provider_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["provider_id"]
+ }
+ },
+ "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.computer_snapshot": {
+ "name": "computer_snapshot",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "elements": {
+ "name": "elements",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "taken_at": {
+ "name": "taken_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": "uuid",
+ "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": {
+ "mcp_servers_credential_id_credentials_id_fk": {
+ "name": "mcp_servers_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_servers",
+ "tableTo": "credentials",
+ "columnsFrom": ["credential_id"],
+ "columnsTo": ["id"],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "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 cd2ea1f0..eae5dd55 100644
--- a/server/drizzle/meta/_journal.json
+++ b/server/drizzle/meta/_journal.json
@@ -57,6 +57,20 @@
"when": 1787330552602,
"tag": "0007_audit_retention_window",
"breakpoints": true
+ },
+ {
+ "idx": 8,
+ "version": "7",
+ "when": 1787339895173,
+ "tag": "0008_whole_retro_girl",
+ "breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "7",
+ "when": 1787342459868,
+ "tag": "0009_lumpy_sunfire",
+ "breakpoints": true
}
]
}
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 63025b6a..b2e017a0 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -35,9 +35,8 @@ import type { ComputerGateway } from "./computer/gateway";
import type { PolicyStore } from "./computer/policy-store";
import { createComputerRoutes } from "./computer/routes";
import { configuredAuthProviders, type DeploymentConfig } from "./config";
-import type { ConnectorAdminService } from "./connectors";
-import { createIntelligenceClient } from "./intelligence-client";
import type { CredentialAdminService, CredentialInput } from "./credentials";
+import { createIntelligenceClient } from "./intelligence-client";
import type { PeopleStore } from "./people/store";
import { createPluginRoutes } from "./plugins/routes";
import type { PluginStore } from "./plugins/store";
@@ -77,7 +76,6 @@ export function createApp(
auditReader?: AuditReader,
credentialService?: CredentialAdminService,
packageStatusReader?: PackageStatusReader,
- connectorService?: ConnectorAdminService,
/**
* The CopilotKit endpoint, already built by the caller.
*
@@ -595,41 +593,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) {
@@ -718,7 +681,11 @@ export function createApp(
if (pluginStore) {
app.route(
"/api/plugins",
- createPluginRoutes(pluginStore, requireUser, canUseBot),
+ createPluginRoutes(pluginStore, requireUser, canUseBot, {
+ encryptionKey: config.keyEncryptionKey,
+ publicUrl: config.publicUrl,
+ appUrl: config.appUrl,
+ }),
);
}
@@ -858,28 +825,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,
@@ -888,6 +833,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/audit.ts b/server/src/audit.ts
index 8391f172..880f98b6 100644
--- a/server/src/audit.ts
+++ b/server/src/audit.ts
@@ -67,6 +67,51 @@ export const auditEventTypes = [
"agent.stream_stalled",
"mcp.call_succeeded",
"mcp.call_rejected",
+ /*
+ * A call this deployment permitted and the vendor did not complete.
+ *
+ * The third outcome, and the one the trail was missing. `call_rejected` is this deployment
+ * declining; `call_succeeded` is a vendor answering. Between them sits a call that passed every
+ * check here and then failed out there — a credential the vendor would not take, an API not
+ * enabled, a timeout — and without a row of its own it was invisible.
+ *
+ * Worse than invisible. `call_succeeded` used to be written before the network call rather than
+ * after, so a call that died at the vendor left a row saying it had succeeded, and the Admin page
+ * agreed. That is the one shape of audit bug worth going out of the way to avoid: a trail that is
+ * confidently wrong is more dangerous than one that is silent, because it is used to rule things
+ * out.
+ */
+ "mcp.call_failed",
+ /*
+ * 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",
+ /*
+ * One person's connector access retired, by them or on their behalf.
+ *
+ * The counterpart to the row above, and the one an auditor reaches for when asked "what happened to
+ * their access". `reason` distinguishes somebody disconnecting their own account from an
+ * administrator removing them, because those are the same effect and very different events.
+ *
+ * `vendorRevoked` says whether the grant at the vendor was withdrawn as well, and is currently
+ * false: removing somebody stops this deployment holding a usable secret, and the grant at Google
+ * outlives it until it is revoked there. Recorded rather than glossed, because a row that implied
+ * otherwise would be worse than no row.
+ */
+ "mcp.account_disconnected",
// 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 dfb68dfd..e9e7339d 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -110,6 +110,32 @@ 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;
+ /**
+ * 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;
/**
@@ -589,6 +615,15 @@ export function loadConfig(
keyEncryptionKey: keyEncryptionKey(environment),
...(managedAgent ? { managedAgent } : {}),
deploymentId: optional(environment, "DEPLOYMENT_ID"),
+ 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/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/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 87fe6a86..20e0d878 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..3652fcb9 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 = () =>
@@ -60,8 +61,21 @@ export const mcpServers = pgTable("mcp_servers", {
*
* A pointer rather than the secret: the vault owns encryption, rotation and revocation, and a
* second copy of a token here would be a second thing to remember to revoke.
+ *
+ * A REAL foreign key, where this was `text` against a `uuid` primary key with none. That is not a
+ * typing nicety. The database was willing to hold a pointer to a credential row that did not
+ * exist, and it did: a test deleted the credential an administrator had registered and left this
+ * column addressing nothing, so the connector reported "no OAuth client registered yet" while the
+ * row still looked configured. Nothing caught it because nothing was checking.
+ *
+ * `restrict`, not `cascade` or `set null`. A credential this server points at should not be
+ * removable out from under it — the two legitimate ways to change it are replacing it, which
+ * repoints this column first, and removing the server, which takes the row with it. Anything else
+ * is a mistake, and should be refused rather than silently tidied into a working-looking state.
*/
- credentialId: text("credential_id"),
+ credentialId: uuid("credential_id").references(() => credentials.id, {
+ onDelete: "restrict",
+ }),
/** What the deployment last heard back from it. `null` until the first successful listing. */
toolsRefreshedAt: timestamp("tools_refreshed_at", { withTimezone: true }),
/** The last failure, kept so the Plugins page can say why a server has no tools. */
@@ -96,6 +110,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/src/index.ts b/server/src/index.ts
index a0f2bfdc..423c366b 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -34,7 +34,6 @@ import {
} from "./computer/provider";
import { createSnapshotStore } from "./computer/snapshot-store";
import { loadConfig } from "./config";
-import { createConnectorAdminService } from "./connectors";
import {
type IdentifyActor,
type IdentifyUser,
@@ -172,6 +171,15 @@ await synchronizeTenantPackage(database, tenantPackage);
const peopleStore = createPeopleStore(
database,
config.auth?.initialAdminEmails ?? [],
+ /*
+ * Removing somebody retires the credentials they granted this deployment.
+ *
+ * A closure rather than the method itself, because the plugin store is built further down: this
+ * has to exist before `auth` does, and that one needs the vault and the policy. Nothing calls this
+ * during module initialisation — it runs when an administrator removes somebody, over HTTP — so by
+ * then the binding is there.
+ */
+ (userId, by) => pluginStore.retireConnectionsFor(userId, by),
);
const identityProviderStore = createIdentityProviderStore(database);
/*
@@ -377,15 +385,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/people/store.ts b/server/src/people/store.ts
index 0caea03f..587f33a9 100644
--- a/server/src/people/store.ts
+++ b/server/src/people/store.ts
@@ -130,9 +130,27 @@ function normalize(email: string): string {
return email.trim().toLowerCase();
}
+/**
+ * What else has to be retired when somebody is removed.
+ *
+ * A seam rather than an import, because this module has no business knowing what a connector is —
+ * and because the list of things a person owns will grow. It exists at all because removing somebody
+ * used to end their sessions and leave every credential they had granted this deployment sitting in
+ * the vault, usable: true of their access, false of the secret, and only the first of those is what
+ * an administrator was told they did.
+ *
+ * Optional, so a deployment without connectors is unchanged and a test can leave it out. Absent
+ * means nothing extra is retired, which is the behaviour this replaced rather than a new risk.
+ */
+export type OwnedCredentialRetirer = (
+ userId: string,
+ by: string,
+) => Promise<{ retired: number }>;
+
export function createPeopleStore(
database: Database,
initialAdminEmails: readonly string[],
+ retireOwnedCredentials?: OwnedCredentialRetirer,
): PeopleStore {
async function list(query: PeopleQuery = {}): Promise {
const limit = Math.min(Math.max(query.limit ?? DEFAULT_PAGE, 1), MAX_PAGE);
@@ -283,6 +301,21 @@ export function createPeopleStore(
.onConflictDoNothing();
await tx.delete(sessions).where(eq(sessions.userId, userId));
});
+
+ /*
+ * After the transaction, and deliberately not inside it.
+ *
+ * Retiring a credential is a write to the vault plus an audit row, and the vault is reached
+ * through its own interface rather than this transaction's handle. Holding the person's removal
+ * open until that finishes would make an unrelated failure able to undo the deny-list row and
+ * the session deletion, which are the two things that must not fail to stick.
+ *
+ * So the order is: stop them getting in, then stop us holding their secret. If the second half
+ * throws, the first is already done and the audit trail shows a removal with no retirement
+ * beside it — which is the honest record of what happened, and is recoverable by removing them
+ * again.
+ */
+ await retireOwnedCredentials?.(userId, revokedBy);
},
async restore(userId) {
diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts
index d918bdb2..c63140f8 100644
--- a/server/src/plugins/catalogue.ts
+++ b/server/src/plugins/catalogue.ts
@@ -18,6 +18,39 @@
* forgery primitive pointed at the deployment's own network.
*/
+/**
+ * How a server is authenticated, and whose credential does it.
+ *
+ * The OAuth addresses are pinned here beside the MCP host, for the same reason and with the same
+ * rule: they come from the vendor's published documentation and are never taken from a caller.
+ * These are where this deployment sends a person's authorization code and receives the refresh
+ * token that stands in for their access, so they are a reviewed source contract too.
+ */
+// Type-only, so naming the transport here creates no import cycle with the registry that resolves it.
+import type { TransportKind } from "./transport";
+
+export type CatalogueAuth =
+ /** Answers without any credential at all. */
+ | { kind: "none" }
+ /** One token, held by the deployment, used for everybody. */
+ | { kind: "deployment-bearer" }
+ /**
+ * The asker's own grant. The deployment registers an OAuth client; each person consents once and
+ * the call runs on their token, so the vendor decides what comes back.
+ */
+ | {
+ kind: "user-oauth";
+ authorizationUrl: string;
+ tokenUrl: string;
+ /** Where a disconnect is sent, so revocation happens at the vendor and not just here. */
+ revokeUrl: string;
+ /**
+ * What to ask a person to consent to. Narrow on purpose: a scope granted by everybody who
+ * connects and used by nothing is a permission nobody remembers agreeing to.
+ */
+ scopes: readonly string[];
+ };
+
export type CatalogueEntry = {
/** Stable slug. Prefixes every tool name, so tools from two servers can never collide. */
key: string;
@@ -36,8 +69,20 @@ export type CatalogueEntry = {
hostPattern?: string;
/** The path the MCP endpoint is served at. Frozen here, never taken from a caller. */
path: string;
- /** True when this server needs a credential from the vault to answer at all. */
- needsCredential: boolean;
+ /**
+ * Whose credential this server is reached with.
+ *
+ * This used to be `needsCredential: boolean`, which said that a credential was required and not
+ * whose it was. That is the one thing about a connector worth being unambiguous about: a reader
+ * who has to guess guesses the deployment's, and a deployment-wide credential pointed at a
+ * per-person system means everybody's question is answered from what one account can see. So the
+ * shape names it, and every entry states it.
+ *
+ * `deployment-bearer` is a token an administrator holds on behalf of everybody. `user-oauth` is
+ * the person's own grant, where the deployment holds only the OAuth client and each person
+ * consents for themselves.
+ */
+ auth: CatalogueAuth;
/**
* The tools this vendor's server exposes that change something.
*
@@ -47,108 +92,82 @@ export type CatalogueEntry = {
* than a write classified as a read.
*/
writeTools: readonly string[];
+ /**
+ * Which protocol reaches this vendor. Absent means MCP, which is what every entry was.
+ *
+ * A field rather than an inference, because the answer is not derivable from the host: Google
+ * serves Drive over both an MCP endpoint and an ordinary REST API, and which one this deployment
+ * uses is a decision about availability and risk rather than a property of the vendor. Naming it
+ * here keeps that decision beside the host it applies to, and makes reversing it a one-line diff.
+ */
+ transport?: TransportKind;
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",
- needsCredential: true,
- 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: "/",
- needsCredential: true,
- 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",
- needsCredential: true,
- 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",
- needsCredential: true,
- 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",
- needsCredential: true,
- writeTools: Object.freeze([
- "create_record",
- "update_record",
- "delete_record",
- ]),
+ 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.
+ */
+ /*
+ * The GA REST API, not `drivemcp.googleapis.com`.
+ *
+ * The MCP server was the original choice and is the better one on paper: vendor-maintained, no
+ * Drive-specific code here at all. It is gated behind the Google Workspace Developer Preview
+ * Program, and an unenrolled project is refused with `The caller does not have permission` —
+ * which describes the project, not the credential, so every check available locally reports a
+ * correct setup. Enrolment is a Workspace-account application with a stated turnaround of days.
+ *
+ * This host has been generally available since 2015. The MCP entry is one line away: set
+ * `transport` back to `mcp` and restore the host and path above. Tool names match Google's MCP
+ * server exactly, so grants survive the swap in either direction.
+ */
+ host: "https://www.googleapis.com",
+ path: "/drive/v3",
+ transport: "google-drive-rest",
+ /*
+ * 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://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/mcp/concept/mcp-server.html",
+ "https://developers.google.com/workspace/guides/configure-mcp-servers",
},
]);
diff --git a/server/src/plugins/google-drive-rest.ts b/server/src/plugins/google-drive-rest.ts
new file mode 100644
index 00000000..99b49bb3
--- /dev/null
+++ b/server/src/plugins/google-drive-rest.ts
@@ -0,0 +1,425 @@
+import { MAX_RESULT_CHARS, type McpCallResult, type McpTool } from "./mcp";
+
+/**
+ * Google Drive, reached over its ordinary REST API instead of its MCP server.
+ *
+ * WHY THIS EXISTS. Google publishes a hosted MCP server for Drive, `drivemcp.googleapis.com`, and
+ * pointing the catalogue at it was the original design: a vendor-maintained server needs no code
+ * here at all. It is gated behind the Google Workspace Developer Preview Program, and refuses an
+ * unenrolled project with `The caller does not have permission` — a statement about the project,
+ * not the credential, so every check available locally says the setup is correct. It is.
+ *
+ * The REST API underneath has been generally available since 2015. So this trades "no code" for "no
+ * dependency on a preview", which for a connector people rely on is the better side of the trade.
+ *
+ * WHAT MAKES IT SWAPPABLE. This module implements the interface {@link ./mcp} already had —
+ * `listTools` and `callTool`, same shapes — rather than inventing one for itself. MCP is therefore
+ * not the default with an exception carved out of it; both are implementations of the same contract,
+ * chosen per catalogue entry by {@link ./transport}. Going back to the MCP server when the preview
+ * opens is one field on one entry, with nothing else in the system aware it changed.
+ *
+ * The TOOL NAMES are deliberately the ones Google's MCP server advertises, character for character.
+ * A grant is stored as `google-drive/search_files`, so keeping the names identical means every grant
+ * an administrator has already made keeps working across the swap, in either direction. Diverging
+ * here would silently turn switching transports into re-granting every tool on every Bot.
+ *
+ * Read-only, and only the tools that can be implemented faithfully. Google's MCP server also
+ * advertises writes; the `drive.readonly` scope refuses them, and nothing here offers them.
+ */
+
+/** Long enough for a slow listing, short enough that a Bot's turn is not held open on it. */
+const REQUEST_TIMEOUT_MS = 30_000;
+
+/** How many files a listing returns before the model is reading a directory rather than an answer. */
+const PAGE_SIZE = 25;
+
+/**
+ * The fields asked for, rather than Drive's default.
+ *
+ * Drive returns a thin projection unless asked, and `webViewLink` is the one worth naming: a result
+ * carrying it lets a Bot cite a file as a link somebody can open, which is the difference between an
+ * answer and an assertion about an answer.
+ */
+const FILE_FIELDS =
+ "id,name,mimeType,modifiedTime,webViewLink,size,owners(emailAddress)";
+
+/**
+ * Google's editor formats, and the plain-text export each one has.
+ *
+ * A Doc has no bytes to download — `alt=media` refuses it — so it has to be exported. Anything not
+ * in here is a real file and is fetched directly.
+ */
+const EXPORTABLE: Record = {
+ "application/vnd.google-apps.document": "text/plain",
+ "application/vnd.google-apps.spreadsheet": "text/csv",
+ "application/vnd.google-apps.presentation": "text/plain",
+};
+
+/**
+ * What this adapter offers, as the same shape a server would have answered `tools/list` with.
+ *
+ * Static, and that is the point of difference from MCP: there is no remote list to discover, so
+ * `refreshTools` records what this code can actually do. A tool listed here that the dispatcher
+ * below does not handle would be advertised to a model and then fail, so the two are kept adjacent.
+ */
+const TOOLS: readonly McpTool[] = Object.freeze([
+ {
+ name: "search_files",
+ description:
+ "Search the files in your Google Drive by name and full text. Returns matching files with their names, types, last modified times and links.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ query: {
+ type: "string",
+ description: "What to look for, in file names and file contents.",
+ },
+ },
+ required: ["query"],
+ },
+ },
+ {
+ name: "list_recent_files",
+ description:
+ "List the files in your Google Drive that changed most recently, newest first.",
+ inputSchema: { type: "object", properties: {} },
+ },
+ {
+ name: "get_file_metadata",
+ description:
+ "Get the name, type, size, owner, last modified time and link for one file, by its id.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ fileId: { type: "string", description: "The file's Drive id." },
+ },
+ required: ["fileId"],
+ },
+ },
+ {
+ name: "read_file_content",
+ description:
+ "Read the text of one file in your Google Drive, by its id. Google Docs, Sheets and Slides are exported as text.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ fileId: { type: "string", description: "The file's Drive id." },
+ },
+ required: ["fileId"],
+ },
+ },
+]);
+
+type Connection = { url: string; token?: string };
+
+/**
+ * No credential is needed to know what this adapter can do, because the answer is in this file.
+ *
+ * This is not a detail. Assuming otherwise made configuring Drive a four-stop journey: an
+ * administrator enabling the connector was refused at "refresh tools" until they had gone to their
+ * own settings page and connected a personal Google account, whose token was then handed to
+ * {@link listTools} — which ignores it — and thrown away. The gate was real and the work behind it
+ * was not.
+ */
+export const listNeedsCredential = false;
+
+/** The same list for everybody, because this adapter's capability is this code rather than a server. */
+export async function listTools(_connection: Connection): Promise {
+ return TOOLS.map((tool) => ({ ...tool }));
+}
+
+/**
+ * One request to Drive, with the caller's own token.
+ *
+ * `token` is never optional in practice here — Drive is `user-oauth`, so the store has already
+ * refused a call with nobody's credential before this module is reached — but it is typed optional
+ * by the shared connection shape, so a missing one is named rather than sent as `Bearer undefined`.
+ */
+async function request(
+ connection: Connection,
+ path: string,
+ query: Record,
+): Promise<{ ok: true; response: Response } | { ok: false; message: string }> {
+ if (!connection.token) {
+ return { ok: false, message: "No credential was available for this call." };
+ }
+
+ const url = new URL(`${connection.url.replace(/\/+$/, "")}${path}`);
+ for (const [key, value] of Object.entries(query)) {
+ url.searchParams.set(key, value);
+ }
+
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ headers: { authorization: `Bearer ${connection.token}` },
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ } catch (error) {
+ return {
+ ok: false,
+ message:
+ error instanceof Error && error.name === "TimeoutError"
+ ? "Google Drive did not answer in time."
+ : `Google Drive could not be reached: ${error instanceof Error ? error.message : String(error)}`,
+ };
+ }
+
+ if (!response.ok) {
+ /*
+ * Google's own sentence, kept. For a 403 this is where it names the API that is not enabled and
+ * gives the console URL, which is the difference between a fix and a guess. Dropping it once
+ * already cost a diagnosis.
+ */
+ const body = await response.text().catch(() => "");
+ let detail = "";
+ try {
+ const parsed = JSON.parse(body) as { error?: { message?: unknown } };
+ if (typeof parsed.error?.message === "string")
+ detail = parsed.error.message;
+ } catch {
+ // Not JSON. The status alone is still worth saying.
+ }
+ return {
+ ok: false,
+ message: detail
+ ? `Google Drive refused this request (${response.status}): ${detail}`
+ : `Google Drive refused this request (${response.status}).`,
+ };
+ }
+
+ return { ok: true, response };
+}
+
+/**
+ * Whether this type's bytes can be read as text at all.
+ *
+ * An allow list, not a deny list. New binary formats appear constantly and each one added to a deny
+ * list is a format that reached a model as mojibake first; the textual families are few and stable.
+ * `application/*` is deliberately not included wholesale — it holds JSON and XML, and also PDFs,
+ * archives and every office format.
+ */
+function isTextual(mimeType: string | undefined): boolean {
+ if (!mimeType) return false;
+ const type = mimeType.split(";")[0].trim().toLowerCase();
+ if (type.startsWith("text/")) return true;
+ return [
+ "application/json",
+ "application/xml",
+ "application/xhtml+xml",
+ "application/javascript",
+ "application/x-ndjson",
+ "application/yaml",
+ "application/x-yaml",
+ "application/sql",
+ "application/toml",
+ ].includes(type);
+}
+
+type DriveFile = {
+ id?: string;
+ name?: string;
+ mimeType?: string;
+ modifiedTime?: string;
+ webViewLink?: string;
+ size?: string;
+ owners?: { emailAddress?: string }[];
+};
+
+/**
+ * One file as a line a model can quote.
+ *
+ * The id is included because every other tool here takes one, and a model that has just been shown a
+ * file it cannot then read is a dead end it will try to talk its way out of.
+ */
+function fileLine(file: DriveFile): string {
+ const name = file.name ?? "(untitled)";
+ /*
+ * A markdown link, not a bare URL beside a name.
+ *
+ * This is K1's acceptance criterion rather than decoration: "the answer carries a link that opens
+ * the actual file". Tool results are drawn through a markdown renderer, so a link is a link a
+ * reader can click, and one the model can carry into its own prose when it cites the file. A bare
+ * URL depends on the renderer choosing to autolink, and reads as noise when it does not.
+ *
+ * The brackets in the name are escaped because a `]` in a file name would otherwise close the link
+ * text early and leave the rest of the name and the URL as literal characters on screen.
+ */
+ const parts = [
+ file.webViewLink
+ ? `[${name.replace(/\[/g, "\\[").replace(/\]/g, "\\]")}](${file.webViewLink})`
+ : name,
+ ];
+ if (file.mimeType) parts.push(file.mimeType);
+ if (file.modifiedTime) parts.push(`modified ${file.modifiedTime}`);
+ // Kept even though the link carries it: every other tool here takes an id, and a model that has
+ // to parse one out of a URL will sometimes get it wrong.
+ if (file.id) parts.push(`id: ${file.id}`);
+ return `- ${parts.join(" · ")}`;
+}
+
+/**
+ * A Drive query string built from what somebody typed.
+ *
+ * The quote is escaped, not stripped. Drive's `q` syntax delimits with single quotes, so an
+ * apostrophe in a search term would otherwise end the clause and change the query's meaning —
+ * searching for `don't` would become a syntax error at best, and at worst a different search than
+ * the one asked for. Escaped, a term is only ever a term.
+ */
+const driveQuery = (query: string) => {
+ const escaped = query.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
+ return `name contains '${escaped}' or fullText contains '${escaped}'`;
+};
+
+/**
+ * Text as an MCP result, through the one function that decides what a model is told.
+ *
+ * Reused rather than reimplemented so the empty case and the size cap behave identically across both
+ * transports. The empty case is the one that matters: a search that matched nothing has to SAY so,
+ * because an empty string reads to a model as "the tool had nothing to say" and gets filled in from
+ * memory — which for a knowledge connector is the exact failure the lane exists to prevent.
+ */
+function asResult(text: string): McpCallResult {
+ const joined = text.trim();
+ if (joined === "") {
+ return {
+ text: "The tool returned no content. Nothing was found, so there is nothing here to answer from.",
+ isError: false,
+ truncated: false,
+ };
+ }
+ if (joined.length <= MAX_RESULT_CHARS) {
+ return { text: joined, isError: false, truncated: false };
+ }
+ return {
+ text: `${joined.slice(0, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`,
+ isError: false,
+ truncated: true,
+ };
+}
+
+const failure = (message: string): McpCallResult => ({
+ text: message,
+ isError: true,
+ truncated: false,
+});
+
+/**
+ * Call one tool.
+ *
+ * Whether the call was permitted is decided before this module, exactly as it is for MCP. This
+ * dispatches and formats, and nothing else — an unknown tool is refused here rather than guessed at,
+ * because a tool name that reaches this point and is not in {@link TOOLS} means the stored tool list
+ * and this code have diverged, which is a bug to surface rather than to absorb.
+ */
+export async function callTool(
+ connection: Connection,
+ toolName: string,
+ args: Record,
+): Promise {
+ const stringArg = (key: string): string | null => {
+ const value = args[key];
+ return typeof value === "string" && value.trim() !== "" ? value : null;
+ };
+
+ if (toolName === "search_files" || toolName === "list_recent_files") {
+ const query = stringArg("query");
+ if (toolName === "search_files" && !query) {
+ return failure("A search needs something to search for.");
+ }
+
+ const result = await request(connection, "/files", {
+ pageSize: String(PAGE_SIZE),
+ fields: `files(${FILE_FIELDS})`,
+ // Drive's own ordering for "recent". Search leaves it to relevance.
+ ...(query ? { q: driveQuery(query) } : { orderBy: "modifiedTime desc" }),
+ });
+ if (!result.ok) return failure(result.message);
+
+ const body = (await result.response.json()) as { files?: DriveFile[] };
+ const files = body.files ?? [];
+ return asResult(files.map(fileLine).join("\n"));
+ }
+
+ if (toolName === "get_file_metadata") {
+ const fileId = stringArg("fileId");
+ if (!fileId) return failure("A file id is needed to look a file up.");
+
+ const result = await request(
+ connection,
+ `/files/${encodeURIComponent(fileId)}`,
+ { fields: FILE_FIELDS },
+ );
+ if (!result.ok) return failure(result.message);
+
+ const file = (await result.response.json()) as DriveFile;
+ const owner = file.owners?.[0]?.emailAddress;
+ return asResult(
+ [
+ fileLine(file),
+ file.size ? `size: ${file.size} bytes` : null,
+ owner ? `owner: ${owner}` : null,
+ ]
+ .filter(Boolean)
+ .join("\n"),
+ );
+ }
+
+ if (toolName === "read_file_content") {
+ const fileId = stringArg("fileId");
+ if (!fileId) return failure("A file id is needed to read a file.");
+
+ /*
+ * The type is looked up first, because how a file is read depends on what it is. A Doc has no
+ * bytes and must be exported; anything else is downloaded. Asking Drive rather than guessing from
+ * the name means a mislabelled file still reads correctly.
+ */
+ const metadata = await request(
+ connection,
+ `/files/${encodeURIComponent(fileId)}`,
+ { fields: "id,name,mimeType" },
+ );
+ if (!metadata.ok) return failure(metadata.message);
+ const file = (await metadata.response.json()) as DriveFile;
+
+ const exportAs = file.mimeType ? EXPORTABLE[file.mimeType] : undefined;
+
+ /*
+ * A file whose bytes are not text is declined by name, not decoded and hoped for.
+ *
+ * `response.text()` on a PDF, an image or a zip produces thousands of replacement characters and
+ * mojibake, and that goes straight into a model's context: it costs the tokens of the real
+ * document, tells the model nothing, and looks enough like content that the model will try to
+ * summarise it. Saying which type it is instead lets the model do the one useful thing available
+ * — name the file and its type, and stop — and keeps a link the person can open themselves.
+ *
+ * Only positively-known-textual types are read. Anything unrecognised is declined, for the same
+ * reason an unknown tool counts as a write: guessing permissively here is not recoverable, since
+ * nothing downstream can tell garbage from content.
+ */
+ if (!exportAs && !isTextual(file.mimeType)) {
+ return failure(
+ `${file.name ?? fileId} is a ${file.mimeType ?? "binary"} file, which this connector cannot read as text. Its metadata and link are available, and somebody can open it themselves.`,
+ );
+ }
+
+ const content = exportAs
+ ? await request(
+ connection,
+ `/files/${encodeURIComponent(fileId)}/export`,
+ { mimeType: exportAs },
+ )
+ : await request(connection, `/files/${encodeURIComponent(fileId)}`, {
+ alt: "media",
+ });
+ if (!content.ok) return failure(content.message);
+
+ const text = await content.response.text();
+ // Named, because a model handed only the body cannot cite what it read.
+ return asResult(`${file.name ?? fileId}\n\n${text}`);
+ }
+
+ return failure(
+ `${toolName} is not a tool this connector implements. The stored tool list is out of date; refresh it on the Plugins page.`,
+ );
+}
diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts
index 4ebc7844..0942fd42 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;
@@ -47,6 +97,107 @@ type Connection = {
token?: string;
};
+/**
+ * The vendor's own sentence out of a failure, when there is one worth reading.
+ *
+ * The transport puts the response body in the message, after a fixed prefix. Two shapes turn up: a
+ * plain error object, and — from Google's Workspace servers — a JSON-RPC result whose `content` holds
+ * the explanation as text under `isError`. Both are worth surfacing; the tool list, which arrives in
+ * the same position under a 403, is not.
+ */
+function reasonFrom(error: unknown): string | null {
+ const message = error instanceof Error ? error.message : String(error);
+ const body = message.slice(message.indexOf("{"));
+ if (!body.startsWith("{")) return null;
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ return null;
+ }
+
+ const asRecord = (value: unknown) =>
+ value && typeof value === "object"
+ ? (value as Record)
+ : {};
+
+ // `{"error": {"message": "..."}}` — how Google's REST APIs refuse.
+ const restMessage = asRecord(asRecord(parsed).error).message;
+ if (typeof restMessage === "string" && restMessage) {
+ return trimmed(restMessage);
+ }
+
+ // `{"result": {"content": [{"text": "..."}], "isError": true}}` — how its MCP servers refuse.
+ const result = asRecord(asRecord(parsed).result);
+ if (result.isError === true && Array.isArray(result.content)) {
+ const text = result.content
+ .map((part) => asRecord(part).text)
+ .find((value): value is string => typeof value === "string" && !!value);
+ if (text) return trimmed(text);
+ }
+
+ return null;
+}
+
+/** Long enough for a sentence and a URL, short enough not to be the wall of JSON this replaced. */
+const trimmed = (value: string) =>
+ value.length > 400 ? `${value.slice(0, 400)}…` : value;
+
+/**
+ * A vendor's failure, as one sentence an operator can act on.
+ *
+ * WHAT THIS REPLACES. The transport throws `Error POSTing to endpoint: `,
+ * and the status lives on the error object rather than in the message — so rewrapping by `.message`
+ * alone threw away the only part that says what went wrong and kept the part that does not.
+ *
+ * Google's Workspace MCP servers make that worse than it sounds. Asked for a tool list with a token
+ * they will not accept, they answer **401, or 403, with a complete and valid tool list in the body** —
+ * verified against the live endpoint. So the message was a wall of successful-looking JSON attached
+ * to a failure, which reads as a parsing bug here rather than as a refusal there.
+ *
+ * The status leads, and the well-known ones are named. A 403 also keeps the vendor's own sentence
+ * where there is one, because that is where Google says which API is not enabled — and each Workspace
+ * product is two APIs, so nothing else can tell "I enabled it" from "it is enabled".
+ */
+function vendorFailure(error: unknown): string {
+ const status =
+ typeof error === "object" && error !== null && "code" in error
+ ? (error as { code?: unknown }).code
+ : undefined;
+
+ if (status === 401) {
+ return "The vendor rejected this credential (401). For a connector reached as the person asking, reconnecting the account is the usual fix; if it persists, the scopes it was granted may not cover this server.";
+ }
+ if (status === 403) {
+ /*
+ * A 403 keeps its reason, unlike a 401.
+ *
+ * This cost a diagnosis. Google refuses a Workspace MCP server with 403 when the API behind it is
+ * not enabled for the project — and the sentence saying so, with the console URL to fix it, is in
+ * the response body. Dropping the body left "the account may lack access, or the API may not be
+ * enabled", which is a guess between two very different problems when the vendor had already
+ * answered the question.
+ *
+ * Worse, each Workspace product is TWO APIs: enabling `drive.googleapis.com` does not enable
+ * `drivemcp.googleapis.com`, so "I enabled it" and "it is enabled" are not the same claim and
+ * only the body can tell them apart.
+ *
+ * Trimmed, because the body may instead be the tool list — the same server answers `tools/list`
+ * with a full, valid list under a 403 — and a wall of JSON is what made the original error
+ * unreadable.
+ */
+ const detail = reasonFrom(error);
+ return detail
+ ? `The vendor accepted the credential and refused the request (403). It said: ${detail}`
+ : "The vendor accepted the credential and refused the request (403). The account may lack access, or the API may not be enabled for this project.";
+ }
+ if (typeof status === "number") {
+ return `The vendor answered ${status}.`;
+ }
+ return error instanceof Error ? error.message : String(error);
+}
+
/**
* Build, use and close a client.
*
@@ -71,9 +222,7 @@ async function withClient(
// Rewrapped so a caller never has to care whether the failure came from the transport, the
// handshake or the call, and so the message that reaches an audit row and an admin page is one
// sentence rather than a stack.
- throw new McpServerError(
- error instanceof Error ? error.message : String(error),
- );
+ throw new McpServerError(vendorFailure(error));
} finally {
await client.close().catch(() => {
// A server that will not say goodbye is not a failure of the work that just succeeded.
@@ -81,6 +230,14 @@ async function withClient(
}
}
+/**
+ * A remote server will not list its tools to nobody, so a credential is required to ask.
+ *
+ * Declared rather than assumed, because the other transport in this deployment answers differently
+ * and the difference is the whole shape of an administrator's setup flow. See {@link ./transport}.
+ */
+export const listNeedsCredential = true;
+
/** What this server says it offers, right now. */
export async function listTools(connection: Connection): Promise {
return withClient(connection, async (client) => {
@@ -123,26 +280,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/src/plugins/oauth.ts b/server/src/plugins/oauth.ts
new file mode 100644
index 00000000..04a461de
--- /dev/null
+++ b/server/src/plugins/oauth.ts
@@ -0,0 +1,294 @@
+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";
+
+/**
+ * Which screen started this, so somebody is returned to the one they left.
+ *
+ * A closed set of two names, never a URL, and that is the security point rather than a style choice.
+ * Carrying a destination through an OAuth flow is how open redirects get built: a
+ * `returnTo=https://evil.test` the callback honours turns this deployment into a redirector that
+ * arrives with a fresh consent behind it. A name cannot express another origin, and an unrecognised
+ * one falls back instead of being followed, so the worst a tampered state achieves is the wrong page
+ * of this app.
+ *
+ * It lives in the SIGNED state rather than on the callback URL because the callback is a request
+ * somebody else's server sent the browser on. Nothing on it is believable by itself.
+ */
+export type ConnectOrigin = "settings" | "admin";
+
+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;
+ /** Where to go back to. Absent reads as `settings`, which is where every flow used to end. */
+ returnTo?: ConnectOrigin;
+};
+
+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}`;
+}
+
+/**
+ * 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 connectedAccountsUrlFor(
+ appUrl: string | undefined,
+ where: { serverId: string } | { failed: true },
+ /**
+ * Which screen to go back to.
+ *
+ * An administrator can start this from the connector's own setup page, and sending them to their
+ * personal settings afterwards would be the same round trip this was meant to remove — they left a
+ * page mid-task and should come back to it. The page they return to shows the same fact either way.
+ */
+ returnTo: ConnectOrigin = "settings",
+): string {
+ const origin = appUrl?.replace(/\/+$/, "") ?? "";
+
+ if (returnTo === "admin") {
+ /*
+ * The admin route takes the server key as its path parameter, so a failure has nowhere generic
+ * to land — and a failed state has no key to build one from. Those cases fall through to the
+ * settings list below, which is the one screen that draws a failure notice.
+ */
+ if ("serverId" in where) {
+ return `${origin}/admin/plugins/${encodeURIComponent(where.serverId)}`;
+ }
+ }
+
+ const base = `${origin}/settings/connected-accounts`;
+
+ /*
+ * Success returns to the account, not the list.
+ *
+ * That page is where the flow started and it is the page that can now say something new: the
+ * account reads Connected, with the scope the vendor granted beside it. The list would only report
+ * the same fact one level further away, leaving somebody to find their way back to check.
+ *
+ * No query parameter either. "It worked" is already told by the thing it is news about, and a
+ * banner saying so next to a row that says so is the same sentence twice.
+ */
+ if ("serverId" in where) {
+ return `${base}/${encodeURIComponent(where.serverId)}`;
+ }
+
+ /*
+ * Failure goes to the list, which is the one screen that draws the notice.
+ *
+ * It is also the only honest destination when the state could not be read: with no state there is
+ * no server id, so there is no account page to return to — and picking one would be a guess about
+ * what somebody had been doing.
+ */
+ return `${base}?connected=failed`;
+}
+
+/** 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,
+ // Only the one name is recognised; anything else becomes the default rather than being carried.
+ returnTo: payload.returnTo === "admin" ? "admin" : "settings",
+ };
+ } 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 e37cbfb5..eb4ab759 100644
--- a/server/src/plugins/routes.ts
+++ b/server/src/plugins/routes.ts
@@ -3,7 +3,17 @@ import { Hono } from "hono";
import type { BotAccessCheck } from "../agents/profile-policy";
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,
+ connectedAccountsUrlFor,
+ signConnectState,
+} from "./oauth";
import {
CatalogueEntryUnknownError,
CustomServerRefusedError,
@@ -36,6 +46,30 @@ export function createPluginRoutes(
* cannot end up calling somebody else's tools by leaving an argument off.
*/
canUseBot: BotAccessCheck,
+ /**
+ * 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.
+ *
+ * Last, and after every required parameter, because that is the only position an optional argument
+ * can hold. Both of these arrived on separate branches as "one more parameter", which is how a
+ * positional list becomes a trap: every argument from here on is optional, so a misplaced one
+ * typechecks and simply does nothing.
+ */
+ 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 }>();
@@ -76,12 +110,30 @@ 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(),
// 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,
}),
);
@@ -159,6 +211,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;
@@ -173,7 +269,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,
@@ -187,6 +286,143 @@ 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,
+ );
+ }
+
+ /*
+ * Where to come back to, as one of two names rather than a URL the caller chose.
+ *
+ * Read from the query and narrowed immediately, so an unrecognised value is the default rather
+ * than something carried into a signed state. See {@link ConnectOrigin}: a destination that could
+ * name another origin is an open redirect with a consent screen in front of it.
+ */
+ const returnTo =
+ context.req.query("returnTo") === "admin" ? "admin" : "settings";
+
+ 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, returnTo },
+ 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 failed = connectedAccountsUrlFor(connect?.appUrl, {
+ failed: true,
+ });
+ 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(
+ connectedAccountsUrlFor(
+ connect.appUrl,
+ { serverId: state.serverId },
+ // From the signed state, so the destination is one this deployment chose, not the browser.
+ state.returnTo,
+ ),
+ );
+ });
+
/**
* Write a skill.
*
@@ -354,6 +590,11 @@ export function createPluginRoutes(
* The grant, the policy and the audit row all happen inside the store, so this endpoint cannot
* accidentally satisfy one of them and skip another. A refusal comes back as 403 with the reason
* the model and the person are both shown, which is the same sentence written to the trail.
+ *
+ * NOTHING IN THIS REPOSITORY CALLS IT. It is what the browser used to post to when a Bot's tool
+ * loop ran client-side; that loop moved to the server, and the client helper for this went with it.
+ * Kept rather than removed, because #37 hardened it with `canUseBot` after that move — so removing
+ * it belongs in a change that says so, not in a merge resolution.
*/
routes.post("/call", requireUser, async (context) => {
const body = (await context.req.json().catch(() => null)) as {
@@ -377,7 +618,19 @@ export function createPluginRoutes(
ref: body.ref,
args: body.args ?? {},
botId: body.agentId,
- actorId: actorEmail(context),
+ /*
+ * The user id, not the address.
+ *
+ * `callTool` keys a per-person connection on `users.id`, so an address here finds nothing and
+ * every call through this route would be answered "you have not connected your account" —
+ * about a connector the person has connected. It never surfaced because a Bot's own tool loop
+ * runs on the server and does not come through here.
+ *
+ * The other uses of `actorEmail` in this file are `by:` on configuration changes, where an
+ * address is the useful thing to record. This one is an identity being resolved, not a name
+ * being written down, and the two are not interchangeable.
+ */
+ actorId: context.var.actor.id,
});
return context.json(result);
} catch (error) {
diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts
index b4caf395..5e71844a 100644
--- a/server/src/plugins/store.ts
+++ b/server/src/plugins/store.ts
@@ -7,13 +7,19 @@ import {
} from "../computer/policy";
import {
type CredentialSecretReader,
+ type CredentialStore,
decryptCredentialForUse,
+ encryptSecret,
} from "../credentials";
import type { Database } from "../db/client";
import {
agentProfiles,
+ // Aliased: `credentials` is already the injected vault interface in this module, and the table and
+ // the interface are two different things to reach for.
+ credentials as credentialRows,
mcpServers,
mcpTools,
+ mcpUserCredentials,
pluginGrants,
skills,
} from "../db/schema";
@@ -24,7 +30,8 @@ import {
customUrlRefusal,
resolveServerUrl,
} from "./catalogue";
-import { callTool as callRemoteTool, listTools, McpServerError } from "./mcp";
+import { McpServerError } from "./mcp";
+import { transportFor } from "./transport";
/**
* Plugins: what this deployment has added, which Bots may use it, and the one path a call takes.
@@ -152,17 +159,153 @@ export function refFromToolName(toolName: string): string | null {
const iso = (value: Date | string | null): string | null =>
value === null ? null : value instanceof Date ? value.toISOString() : value;
+/**
+ * Whose credential reaches this server, as the trail names it.
+ *
+ * One definition, because this was two: `connectionTokenFor` returned it and the audit payload
+ * recomputed the same condition a few lines later. Two expressions for one fact can disagree, and
+ * the one place that would show is an audit row claiming a call ran as somebody it did not — which is
+ * the row a per-person connector exists to be able to trust.
+ *
+ * `deployment` for a shared token; the asker's own id for a server reached as the person asking.
+ */
+const reachedAsFor = (entry: CatalogueEntry | null, actorId: string): string =>
+ entry?.auth.kind === "user-oauth" ? actorId : "deployment";
+
+/**
+ * Where this server actually is, when the stored row and the catalogue disagree.
+ *
+ * `mcp_servers.url` is written once, when a server is added, by copying what the catalogue said at
+ * the time. That makes it a cache of a reviewed decision — and a cache nothing invalidates. Moving
+ * Google Drive from its preview MCP host to its GA REST host changed the catalogue and left every
+ * deployment that had already added Drive calling the old address, with no way to tell from any
+ * screen: the row looks exactly as intentional as it did the day it was written.
+ *
+ * So for an entry with a PINNED host, the catalogue wins. It is the reviewed source contract, and a
+ * host it no longer names is a host this deployment has decided not to talk to. Editing the
+ * catalogue is the act of changing where a first-party server is, and it should take effect.
+ *
+ * The stored value still wins for the two cases where it is the only truth: a custom server an
+ * administrator added by URL, which has no entry at all, and a per-instance vendor whose `host` is
+ * null because the customer's own hostname is the answer.
+ */
+function effectiveUrl(
+ row: { id: string; url: string },
+ entry: CatalogueEntry | null,
+): string {
+ if (!entry || entry.host === null) return row.url;
+ return resolveServerUrl(row.id)?.url ?? row.url;
+}
+
+/**
+ * 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;
- 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;
+ /**
+ * 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;
+ /*
+ * Held rather than resolved, because the transport is a property of the entry and is not known
+ * until a call names one. An injected vendor still wins over both, which is what keeps a test able
+ * to assert what a call was about to go out with.
+ */
+ const injectedVendor = options.callVendor;
+ const exchangeRefreshToken =
+ options.exchangeRefreshToken ?? exchangeRefreshTokenOverHttp;
async function grantsFor(kind: PluginKind, refs: string[]) {
if (refs.length === 0) return new Map();
@@ -184,12 +327,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 }> {
+ 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 };
+ }
+
+ /*
+ * 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 };
}
async function requireServer(serverId: string) {
@@ -364,13 +618,54 @@ 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 whether it is needed at all is the transport's answer rather
+ * than an assumption here. Where listing means asking a remote server — MCP — a `user-oauth`
+ * vendor has no deployment credential to ask with, so the listing runs on the grant of whoever
+ * pressed refresh, and an administrator who has not connected gets a refusal that lands in
+ * `lastError`. That is the honest state: until somebody has connected, this deployment genuinely
+ * does not know what that server offers.
+ *
+ * Where the tool list is this deployment's own code, nothing is asked and no credential is
+ * consulted. Requiring one anyway is what made setting Drive up a round trip through an
+ * administrator's personal settings page for a token that was then discarded.
+ *
+ * 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);
- const tools = await listTools({ url: row.url, token });
+ // The entry decides the protocol. For a custom server there is no entry, and MCP is right.
+ const transport = transportFor(entry);
+
+ /*
+ * A credential only when listing actually needs one.
+ *
+ * Where it is needed, it is taken from the same selection the call path uses rather than by
+ * decrypting `row.credentialId` — which is what this used to do, and which for a `user-oauth`
+ * server would have sent the deployment's OAuth client secret to the vendor as somebody's
+ * access token. One answer to "what token does this server get", and it cannot be a secret of
+ * the wrong kind.
+ *
+ * Where it is NOT needed, asking anyway is not a harmless extra check. For a `user-oauth`
+ * server that call refuses unless the person pressing the button has connected their own
+ * account — so an administrator setting Drive up was blocked at "refresh tools" and sent to
+ * their personal settings page to grant access, so that a token could be minted and handed to
+ * a function that discards it. The gate outlived the reason for it.
+ */
+ const token = transport.listNeedsCredential
+ ? (await connectionTokenFor(row, entry, actorId)).token
+ : undefined;
+
+ const tools = await transport.listTools({
+ url: effectiveUrl(row, entry),
+ token,
+ });
await database.delete(mcpTools).where(eq(mcpTools.serverId, serverId));
if (tools.length > 0) {
@@ -440,7 +735,7 @@ export function createPluginStore(options: PluginStoreOptions) {
id: row.id,
title: row.title,
vendor: row.vendor,
- url: row.url,
+ url: effectiveUrl(row, entry),
summary: entry?.summary ?? "",
docsUrl: entry?.docsUrl ?? "",
provenance: row.provenance,
@@ -688,6 +983,268 @@ 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) ?? "",
+ }));
+ },
+
+ /**
+ * Retire every connector credential belonging to one person.
+ *
+ * WHAT THIS IS FOR. "We removed their access" has to be true of the thing that matters, which is
+ * the refresh token sitting at the vendor. Removing somebody from the People screen used to end
+ * their sessions and add them to the deny list, and leave their Google grant entirely intact in
+ * this deployment's vault. They could not exercise it — the actor comes from a session they no
+ * longer get — but the deployment still held a usable secret for a person who had been removed,
+ * which is not what an administrator was told they did, and is the first thing a customer asks
+ * about a per-person connector.
+ *
+ * LOOKED UP IN THE VAULT, NOT THROUGH THE JOIN TABLE. `mcp_user_credentials.user_id` cascades on
+ * a user row being deleted, so by the time somebody is gone the join row can be gone too and the
+ * credential is orphaned: unrevoked, referenced by nothing, reachable from no screen and by no
+ * code path. `credentials.key_id` holds the user id for an `mcp_user_token`, so the vault can
+ * still be asked directly — which makes this work for the person who was removed and for the one
+ * whose row was deleted underneath it.
+ *
+ * The join rows go too, so the account pages stop claiming a connection this deployment can no
+ * longer use.
+ *
+ * NOT vendor-side revocation. That needs the OAuth client and the vendor's revoke endpoint, and
+ * it belongs with disconnect. This is the half that stops us holding the secret; the grant at
+ * Google outlives it until somebody revokes it there. Said plainly rather than implied, because
+ * the difference matters to whoever has to answer for it.
+ */
+ async retireConnectionsFor(
+ userId: string,
+ by: string,
+ ): Promise<{ retired: number }> {
+ if (!userId) return { retired: 0 };
+
+ const owned = await database
+ .select({
+ id: credentialRows.id,
+ provider: credentialRows.provider,
+ revokedAt: credentialRows.revokedAt,
+ })
+ .from(credentialRows)
+ .where(
+ and(
+ eq(credentialRows.kind, "mcp_user_token"),
+ eq(credentialRows.keyId, userId),
+ ),
+ );
+
+ let retired = 0;
+ for (const credential of owned) {
+ // Already revoked is not a failure. Retiring twice is something an administrator can
+ // legitimately do, and the second time should be quiet rather than an error.
+ if (credential.revokedAt) continue;
+ await credentials.revoke(credential.id);
+ retired += 1;
+ await recordAuditEvent(auditStore, {
+ eventType: "mcp.account_disconnected",
+ targetType: "mcp_server",
+ targetId: credential.provider,
+ payload: {
+ actor: by,
+ server: credential.provider,
+ owner: userId,
+ /*
+ * Why, because the two reasons are not the same event to a reader. Somebody disconnecting
+ * their own account is a person changing their mind; an administrator removing somebody
+ * is an offboarding, and an auditor asking "what happened to their access" wants to see
+ * which one this was.
+ */
+ reason: "person_removed",
+ vendorRevoked: false,
+ },
+ });
+ }
+
+ await database
+ .delete(mcpUserCredentials)
+ .where(eq(mcpUserCredentials.userId, userId));
+
+ return { retired };
+ },
+
/**
* May this Bot use this plugin?
*
@@ -808,37 +1365,117 @@ export function createPluginStore(options: PluginStoreOptions) {
const verdict = evaluateActionPolicy(options.policy(), context);
- await recordAuditEvent(auditStore, {
- eventType: verdict.forward ? "mcp.call_succeeded" : "mcp.call_rejected",
- targetType: "mcp_tool",
- targetId: input.ref,
- payload: {
- actor: input.actorId,
- bot: input.botId,
- server: serverId,
- tool: toolName,
- effect,
- decision: {
- allowed: verdict.allowed,
- mode: verdict.mode,
- rule: verdict.matched,
- source: verdict.source,
- carriedOut: verdict.forward,
- },
+ /*
+ * The parts of the row that are known before the attempt, held rather than written.
+ *
+ * Everything here is a fact about the decision, and the decision is final at this point. What
+ * is NOT yet known is whether the call worked, which is why this is a variable and not a write:
+ * the row goes down once, after the outcome exists.
+ */
+ const decided = {
+ actor: input.actorId,
+ bot: input.botId,
+ server: serverId,
+ tool: toolName,
+ effect,
+ /*
+ * Whose credential this call goes out with.
+ *
+ * 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: reachedAsFor(entry, input.actorId),
+ decision: {
+ allowed: verdict.allowed,
+ mode: verdict.mode,
+ rule: verdict.matched,
+ source: verdict.source,
+ carriedOut: verdict.forward,
},
- });
+ };
+ /*
+ * A refusal is written here, because there is no attempt to wait for.
+ *
+ * This deployment declining is the whole event, and it is recorded before the throw so that a
+ * refusal cannot be lost by the caller's error handling.
+ */
if (!verdict.forward) {
+ await recordAuditEvent(auditStore, {
+ eventType: "mcp.call_rejected",
+ targetType: "mcp_tool",
+ targetId: input.ref,
+ payload: decided,
+ });
throw new PluginRefusedError(verdict.reason, verdict.matched);
}
- const token = await tokenFor(row.credentialId);
- const result = await callRemoteTool(
- { url: row.url, token },
- toolName,
- args,
- );
- return { text: result.text, isError: result.isError };
+ /*
+ * Attempt first, record second.
+ *
+ * The row now says what HAPPENED rather than what was permitted. It used to be written here,
+ * before the two lines below, which meant a call that died at the vendor left `call_succeeded`
+ * behind it — and a per-person connector fails at exactly these two lines: no connection for
+ * the asker, a refresh token the vendor no longer accepts, an API not enabled for the project.
+ * Every one of those was invisible, and worse than invisible, because the trail asserted the
+ * opposite.
+ *
+ * `isError` counts as a failure. A vendor that answers the protocol correctly to say the tool
+ * itself failed has not completed the call, and a reader counting successes should not be told
+ * it did.
+ */
+ try {
+ const { token } = await connectionTokenFor(row, entry, input.actorId);
+ const vendor = injectedVendor ?? transportFor(entry).callTool;
+ const result = await vendor(
+ { url: effectiveUrl(row, entry), token },
+ toolName,
+ args,
+ );
+ await recordAuditEvent(auditStore, {
+ eventType: result.isError ? "mcp.call_failed" : "mcp.call_succeeded",
+ targetType: "mcp_tool",
+ targetId: input.ref,
+ /*
+ * The vendor's own words, when it is reporting a failure.
+ *
+ * Only on the failure branch, and this is the whole point of the distinction. A successful
+ * result is somebody's data — a file listing, a document — and it has no business in an
+ * audit row that an administrator can read. An `isError` result is a message written for
+ * whoever operates this deployment, and it is the most useful sentence available: Google
+ * refuses the Drive MCP server with "The caller does not have permission", which named the
+ * problem after a generic message had already cost a round of probing.
+ *
+ * Capped, because the failure branch is not a promise about length.
+ */
+ payload: result.isError
+ ? {
+ ...decided,
+ failure:
+ result.text.slice(0, 400) || "the tool reported an error",
+ }
+ : decided,
+ });
+ return { text: result.text, isError: result.isError };
+ } catch (error) {
+ /*
+ * Recorded, then rethrown unchanged. The caller's behaviour is unaffected — what changes is
+ * that the failure now exists in the trail, which is where somebody asking "is this connector
+ * working" looks. The vendor's own sentence is kept, since for a 403 that is the sentence
+ * naming which API is not enabled.
+ */
+ await recordAuditEvent(auditStore, {
+ eventType: "mcp.call_failed",
+ targetType: "mcp_tool",
+ targetId: input.ref,
+ payload: {
+ ...decided,
+ failure: error instanceof Error ? error.message : String(error),
+ },
+ });
+ throw error;
+ }
},
};
}
diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts
index 2c284fb7..0bc35518 100644
--- a/server/src/plugins/tools.ts
+++ b/server/src/plugins/tools.ts
@@ -80,7 +80,22 @@ export async function grantedTools(options: {
botId,
actorId,
});
- return result.text;
+ /*
+ * A vendor's error is named as one, not handed over as content.
+ *
+ * `isError` used to be dropped here, and it cost a diagnosis. Google refused the Drive MCP
+ * server with `isError: true` and the text "The caller does not have permission"; the model
+ * received that as an ordinary result, believed it, and told the person it had no access to
+ * their Drive — which read as the Bot being confused rather than as the vendor refusing.
+ *
+ * The prefix is the vendor's, and says so. It is deliberately NOT `REFUSAL_MARKER`: that one
+ * means this deployment declined, and the transcript draws it as a boundary holding. A vendor
+ * saying no is a different fact with a different fix, and collapsing the two would make a
+ * misconfigured connector look like a policy working correctly.
+ */
+ return result.isError
+ ? `The vendor reported an error: ${result.text}`
+ : result.text;
} catch (error) {
if (error instanceof PluginRefusedError) {
return `${REFUSAL_MARKER} ${error.message}`;
diff --git a/server/src/plugins/transport.ts b/server/src/plugins/transport.ts
new file mode 100644
index 00000000..1a024f09
--- /dev/null
+++ b/server/src/plugins/transport.ts
@@ -0,0 +1,70 @@
+import type { CatalogueEntry } from "./catalogue";
+import * as driveRest from "./google-drive-rest";
+import * as mcp from "./mcp";
+import type { McpCallResult, McpTool } from "./mcp";
+
+/**
+ * How this deployment reaches one vendor: which protocol, chosen per catalogue entry.
+ *
+ * WHY THIS EXISTS. Every connector used to be MCP, so "the transport" was an import. Google's Drive
+ * MCP server turned out to be gated behind a developer preview, and the same product's ordinary REST
+ * API is generally available — so one vendor needed a second way in, and a second way in wants a
+ * seam rather than a branch at each call site.
+ *
+ * The interface is MCP's OWN, unchanged: `listTools` and `callTool`, the two functions
+ * {@link ./mcp} already exported, with the shapes it already used. That direction matters. Had the
+ * REST adapter been given its own interface with MCP adapted to fit, MCP would have become a special
+ * case of a shape invented for Drive. As it is, MCP is the contract and the adapter conforms to it,
+ * which is why swapping back is one field on one entry and not a refactor.
+ *
+ * There are exactly two call sites in the whole system — the tool listing and the tool call — and
+ * both take a transport from here. Nothing else, including the OAuth flow, the per-person credential
+ * selection, the grants, the policy engine and the audit trail, knows which protocol is underneath.
+ */
+export type VendorTransport = {
+ /**
+ * Whether discovering the tool list needs somebody's credential.
+ *
+ * True for MCP, where the list is an answer from a remote server that will not give it up
+ * unauthenticated. False for an adapter whose tool list is this code, where there is nothing to ask
+ * and nobody to ask it of.
+ *
+ * It is on the transport rather than assumed by the caller because getting it wrong is a whole
+ * broken setup flow. Assumed true, an administrator configuring Drive was sent to their own
+ * settings page to connect a personal account, purely so a token could be minted, passed to a
+ * function that ignores it, and discarded — then sent back to press refresh. Nothing about that
+ * sequence hinted that the middle step was doing no work.
+ */
+ listNeedsCredential: boolean;
+ listTools(connection: { url: string; token?: string }): Promise;
+ callTool(
+ connection: { url: string; token?: string },
+ toolName: string,
+ args: Record,
+ ): Promise;
+};
+
+/**
+ * The protocols a catalogue entry may name.
+ *
+ * A closed union rather than a string, so adding one is a change to this file and to the registry
+ * below together. An entry naming a transport that does not exist should not typecheck.
+ */
+export type TransportKind = "mcp" | "google-drive-rest";
+
+const TRANSPORTS: Record = {
+ mcp,
+ "google-drive-rest": driveRest,
+};
+
+/**
+ * Which transport serves this entry.
+ *
+ * MCP for anything that does not say otherwise, which covers every catalogue entry that omits the
+ * field and — importantly — every server an administrator added by URL, where there is no entry at
+ * all. A custom server is somebody else's MCP endpoint by definition, so the absent case and the
+ * default case are the same answer for the same reason.
+ */
+export function transportFor(entry: CatalogueEntry | null): VendorTransport {
+ return TRANSPORTS[entry?.transport ?? "mcp"];
+}
diff --git a/server/src/tenant-package.ts b/server/src/tenant-package.ts
index 1aba4baa..2360b446 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 68ff108e..323d860a 100644
--- a/server/tests/agent-routes.test.ts
+++ b/server/tests/agent-routes.test.ts
@@ -551,9 +551,17 @@ describe("agent route composition", () => {
api: { getSession: async () => session },
},
{ rolesForUser: async () => ["user"] },
- // Positions 4-10: auditReader, credentialService, packageStatusReader, connectorService,
- // copilotHandler, computerGateway, computerPolicy.
- undefined,
+ /*
+ * Positions 4-9: auditReader, credentialService, packageStatusReader, copilotHandler,
+ * computerGateway, computerPolicy. `store` is position 10, agentProfileStore.
+ *
+ * Six placeholders, down from seven on both sides of the merge that produced this. Each side
+ * had removed one parameter — `connectorService` here, `computerClient` on main — so both runs
+ * were seven long and textually identical, and only the comment conflicted. Taking either
+ * side's run would have left `store` one slot too far along, in `channelEvents`, where nothing
+ * would have complained: every parameter from 4 on is optional, so a misplaced argument is a
+ * silent pass and the assertions below would fail for no visible reason.
+ */
undefined,
undefined,
undefined,
diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts
index 58120272..af81fc33 100644
--- a/server/tests/channel-routes.test.ts
+++ b/server/tests/channel-routes.test.ts
@@ -305,9 +305,9 @@ describe("channel route composition", () => {
api: { getSession: async () => session },
},
{ rolesForUser: async () => ["user"] },
- // Positions 4-11, ending at agentProfileStore. The computer gateway and policy store come
- // before these placeholders.
- undefined,
+ // Positions 4-10, ending at agentProfileStore. `store` is position 11, channelStore. One
+ // shorter than either side of the merge that produced this: see agent-routes.test.ts for why
+ // a wrong count here fails silently rather than as a type error.
undefined,
undefined,
undefined,
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/google-drive-rest.test.ts b/server/tests/google-drive-rest.test.ts
new file mode 100644
index 00000000..ec69e08e
--- /dev/null
+++ b/server/tests/google-drive-rest.test.ts
@@ -0,0 +1,239 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { callTool, listTools } from "../src/plugins/google-drive-rest";
+import { transportFor } from "../src/plugins/transport";
+import { catalogueEntry } from "../src/plugins/catalogue";
+
+/**
+ * The Drive REST adapter, asserted without Google.
+ *
+ * `fetch` is replaced rather than a server started, because what is under test is the translation:
+ * which URL a tool becomes, what a refusal reads as, and that an empty listing says so in words.
+ * None of that needs a network, and all of it is what breaks when Drive's shapes are misremembered.
+ */
+
+const connection = {
+ url: "https://www.googleapis.com/drive/v3",
+ token: "test-token",
+};
+
+const realFetch = globalThis.fetch;
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+/** Records what was requested and answers with a fixed body. */
+function stubFetch(
+ body: unknown,
+ init: { status?: number; text?: string } = {},
+) {
+ const calls: { url: string; authorization: string | null }[] = [];
+ globalThis.fetch = (async (input: string | URL, options?: RequestInit) => {
+ calls.push({
+ url: String(input),
+ authorization: new Headers(options?.headers).get("authorization") ?? null,
+ });
+ const payload = init.text ?? JSON.stringify(body);
+ return new Response(payload, {
+ status: init.status ?? 200,
+ headers: { "content-type": "application/json" },
+ });
+ }) as typeof fetch;
+ return calls;
+}
+
+describe("the adapter is the transport the catalogue asks for", () => {
+ test("the Drive entry resolves to this adapter, not to MCP", async () => {
+ const entry = catalogueEntry("google-drive");
+ expect(entry?.transport).toBe("google-drive-rest");
+ // Identity, not shape: proves the registry wired this module rather than something MCP-shaped.
+ expect(transportFor(entry).callTool).toBe(callTool);
+ });
+
+ test("a server with no catalogue entry falls back to MCP", () => {
+ // A custom server an administrator added by URL is somebody else's MCP endpoint by definition.
+ expect(transportFor(null).callTool).not.toBe(callTool);
+ });
+
+ test("every advertised tool is one the dispatcher handles", async () => {
+ const tools = await listTools(connection);
+ stubFetch({ files: [] });
+ for (const tool of tools) {
+ // Called with no arguments on purpose. A handled tool complains about a missing argument or
+ // answers; an unhandled one says it is not implemented, which is the failure being excluded.
+ const result = await callTool(connection, tool.name, {});
+ expect(result.text).not.toContain("is not a tool this connector");
+ }
+ });
+});
+
+describe("a search becomes the right Drive request", () => {
+ test("the query is sent as a Drive q clause, with the caller's token", async () => {
+ const calls = stubFetch({ files: [] });
+ await callTool(connection, "search_files", { query: "roadmap" });
+
+ expect(calls).toHaveLength(1);
+ const url = new URL(calls[0].url);
+ expect(url.origin + url.pathname).toBe(
+ "https://www.googleapis.com/drive/v3/files",
+ );
+ expect(url.searchParams.get("q")).toBe(
+ "name contains 'roadmap' or fullText contains 'roadmap'",
+ );
+ expect(calls[0].authorization).toBe("Bearer test-token");
+ });
+
+ /*
+ * THE INJECTION CASE. Drive's `q` syntax delimits with single quotes, so an apostrophe in a search
+ * term would close the clause early — turning a search for somebody's file into a different query
+ * than the one asked for, or a syntax error. Escaped, a term is only ever a term.
+ */
+ test("an apostrophe in the query cannot break out of the clause", async () => {
+ const calls = stubFetch({ files: [] });
+ await callTool(connection, "search_files", { query: "don't ship" });
+
+ const q = new URL(calls[0].url).searchParams.get("q");
+ expect(q).toBe(
+ "name contains 'don\\'t ship' or fullText contains 'don\\'t ship'",
+ );
+ });
+
+ test("recent files are ordered by Drive rather than filtered", async () => {
+ const calls = stubFetch({ files: [] });
+ await callTool(connection, "list_recent_files", {});
+
+ const url = new URL(calls[0].url);
+ expect(url.searchParams.get("orderBy")).toBe("modifiedTime desc");
+ expect(url.searchParams.has("q")).toBe(false);
+ });
+
+ test("a search with nothing to search for is refused before the network", async () => {
+ const calls = stubFetch({ files: [] });
+ const result = await callTool(connection, "search_files", {});
+
+ expect(result.isError).toBe(true);
+ expect(calls).toHaveLength(0);
+ });
+});
+
+describe("what a model is told", () => {
+ test("a match is named, with the id it needs to read it", async () => {
+ stubFetch({
+ files: [
+ {
+ id: "abc123",
+ name: "Roadmap",
+ mimeType: "application/vnd.google-apps.document",
+ modifiedTime: "2026-08-21T10:00:00Z",
+ webViewLink: "https://docs.google.com/document/d/abc123",
+ },
+ ],
+ });
+
+ const result = await callTool(connection, "search_files", {
+ query: "roadmap",
+ });
+ expect(result.isError).toBe(false);
+ expect(result.text).toContain("Roadmap");
+ // Every other tool here takes an id, so a result without one is a dead end.
+ expect(result.text).toContain("abc123");
+ expect(result.text).toContain("https://docs.google.com/document/d/abc123");
+ });
+
+ /*
+ * The empty case, stated in words rather than returned as an empty string. An empty result reads to
+ * a model as "the tool had nothing to say" and gets filled in from memory, which for a knowledge
+ * connector is the exact failure the whole lane exists to prevent.
+ */
+ test("nothing found says so, and is not an error", async () => {
+ stubFetch({ files: [] });
+ const result = await callTool(connection, "search_files", {
+ query: "nothing matches this",
+ });
+
+ expect(result.isError).toBe(false);
+ expect(result.text).toContain("Nothing was found");
+ });
+
+ test("Google's own refusal is passed through, not replaced", async () => {
+ stubFetch(
+ {},
+ {
+ status: 403,
+ text: JSON.stringify({
+ error: { message: "Google Drive API has not been used in project 1" },
+ }),
+ },
+ );
+
+ const result = await callTool(connection, "search_files", { query: "x" });
+ expect(result.isError).toBe(true);
+ // The sentence naming what to fix, which is the whole reason the body is kept.
+ expect(result.text).toContain("has not been used in project 1");
+ expect(result.text).toContain("403");
+ });
+});
+
+describe("reading a file asks Drive what it is first", () => {
+ test("a Google Doc is exported as text, never downloaded", async () => {
+ const calls = stubFetch({
+ id: "doc1",
+ name: "Notes",
+ mimeType: "application/vnd.google-apps.document",
+ });
+
+ await callTool(connection, "read_file_content", { fileId: "doc1" });
+
+ expect(calls).toHaveLength(2);
+ // `alt=media` refuses an editor file outright, so the export path is not an optimisation.
+ expect(calls[1].url).toContain("/files/doc1/export");
+ expect(new URL(calls[1].url).searchParams.get("mimeType")).toBe(
+ "text/plain",
+ );
+ });
+
+ test("an ordinary text file is downloaded", async () => {
+ const calls = stubFetch({
+ id: "txt1",
+ name: "notes.txt",
+ mimeType: "text/plain",
+ });
+
+ await callTool(connection, "read_file_content", { fileId: "txt1" });
+
+ expect(new URL(calls[1].url).searchParams.get("alt")).toBe("media");
+ expect(calls[1].url).not.toContain("/export");
+ });
+
+ /*
+ * A PDF is declined by name rather than decoded and hoped for.
+ *
+ * `response.text()` on binary produces thousands of replacement characters, and that goes straight
+ * into a model's context: it costs the tokens of the real document, says nothing, and looks enough
+ * like content that the model will try to summarise it. The assertion that matters is the second
+ * one — the download is never even attempted, so the bytes never exist to be mangled.
+ */
+ test("a binary file is declined instead of being read as text", async () => {
+ const calls = stubFetch({
+ id: "pdf1",
+ name: "Contract.pdf",
+ mimeType: "application/pdf",
+ });
+
+ const result = await callTool(connection, "read_file_content", {
+ fileId: "pdf1",
+ });
+
+ expect(result.isError).toBe(true);
+ expect(result.text).toContain("application/pdf");
+ // One call: the metadata lookup. No download followed it.
+ expect(calls).toHaveLength(1);
+ });
+
+ test("a file id is required, and no request is made without one", async () => {
+ const calls = stubFetch({});
+ const result = await callTool(connection, "read_file_content", {});
+
+ expect(result.isError).toBe(true);
+ expect(calls).toHaveLength(0);
+ });
+});
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/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);
+ });
+});
diff --git a/server/tests/people-routes.test.ts b/server/tests/people-routes.test.ts
index 71bc323d..92bdca3a 100644
--- a/server/tests/people-routes.test.ts
+++ b/server/tests/people-routes.test.ts
@@ -64,8 +64,16 @@ function appWith(
api: { getSession: async () => ({ user: ADMIN }) },
} as never,
{ rolesForUser: async () => [role] },
- // Positions 4-18 are the other stores; people is last.
- ...(Array.from({ length: 15 }) as never[]),
+ /*
+ * Positions 4-17 are the other stores; `store` is 18, peopleStore.
+ *
+ * Fourteen, not fifteen. This arrived from main written against a signature that still had
+ * `connectorService`, which the knowledge lane removed along with the connector it served — so
+ * the count is one shorter here. Every parameter from 4 on is optional, so getting it wrong is a
+ * silent type-check pass: the store lands in `ssoProviderCount`, the people routes see no store,
+ * and every test below fails with a 503 that says nothing about why.
+ */
+ ...(Array.from({ length: 14 }) as never[]),
store as never,
);
diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts
index 7efca89e..5836ae6c 100644
--- a/server/tests/plugin-catalogue.test.ts
+++ b/server/tests/plugin-catalogue.test.ts
@@ -19,38 +19,53 @@ 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://www.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"),
- ).toBe(false);
- expect(
- hostAdmissible(atlassian!, "https://evil.test/mcp.atlassian.com"),
- ).toBe(false);
- expect(hostAdmissible(atlassian!, "http://mcp.atlassian.com")).toBe(false);
+ expect(hostAdmissible(drive!, "https://www.googleapis.com.evil.test")).toBe(
+ false,
+ );
+ expect(hostAdmissible(drive!, "https://evil.test/www.googleapis.com")).toBe(
+ false,
+ );
+ expect(hostAdmissible(drive!, "http://www.googleapis.com")).toBe(false);
+ // The MCP host this entry used to name. Now inadmissible, which is the point of pinning: moving
+ // the entry to the GA API is also a decision to stop talking to the preview endpoint.
+ expect(hostAdmissible(drive!, "https://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,19 +76,11 @@ 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",
+ // 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://www.googleapis.com/drive/v3",
);
- expect(resolved?.url).toBe(
- "https://acme.service-now.com/sncapps/mcp-server",
- );
- });
-
- 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", () => {
@@ -90,33 +97,113 @@ 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("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",
+ "slack",
+ "salesforce",
+ "servicenow",
+ ]) {
+ expect(catalogueEntry(key)).toBeNull();
+ expect(resolveServerUrl(key)).toBeNull();
+ }
+ });
+});
+
+describe("Google Drive", () => {
+ const drive = catalogueEntry("google-drive");
+
+ test("resolves to the one address Google publishes for it", () => {
+ expect(drive).not.toBeNull();
+ /*
+ * The GA REST API, not the MCP server. Google publishes both; the MCP one is gated behind the
+ * Workspace Developer Preview Program and refuses an unenrolled project with a message about
+ * permission that describes the project rather than the credential. Swapping back is `transport`
+ * plus these two fields, which is why the transport is asserted alongside the address.
+ */
+ expect(resolveServerUrl("google-drive")?.url).toBe(
+ "https://www.googleapis.com/drive/v3",
+ );
+ expect(drive?.transport).toBe("google-drive-rest");
+ });
+
+ 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")!;
+ 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-oauth.test.ts b/server/tests/plugin-oauth.test.ts
new file mode 100644
index 00000000..570cde21
--- /dev/null
+++ b/server/tests/plugin-oauth.test.ts
@@ -0,0 +1,306 @@
+import { describe, expect, test } from "bun:test";
+import { catalogueEntry } from "../src/plugins/catalogue";
+import {
+ authorizationUrlFor,
+ challengeFor,
+ createVerifier,
+ readConnectState,
+ redirectUriFor,
+ connectedAccountsUrlFor,
+ 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",
+ // Absent on the way in, and a definite answer on the way out: a state written before this
+ // field existed still reads as the destination every flow used to have.
+ returnTo: "settings",
+ });
+ });
+
+ test("the screen to return to survives the round trip", () => {
+ const signed = signConnectState(
+ {
+ userId: "user-1",
+ serverId: "google-drive",
+ verifier: "v-1",
+ returnTo: "admin",
+ },
+ KEY,
+ NOW,
+ );
+ expect(readConnectState(signed, KEY, NOW)?.returnTo).toBe("admin");
+ });
+
+ /*
+ * THE OPEN REDIRECT THIS CANNOT BECOME. A destination carried through an OAuth flow is the classic
+ * shape of one: the callback arrives with a fresh consent behind it, and anything it is willing to
+ * redirect to is somewhere an attacker can send a person from a link that looked legitimate.
+ *
+ * The defence is that the field cannot express another origin at all. Only "admin" is recognised;
+ * everything else — a URL, a protocol-relative host, a path traversal — reads back as the default.
+ * Asserted through a SIGNED state, because a valid signature is exactly what an attacker would not
+ * have, and the point is that the narrowing does not depend on the signature to hold.
+ */
+ test("a destination that names anywhere else reads back as the default", () => {
+ for (const hostile of [
+ "https://evil.test",
+ "//evil.test",
+ "/admin/plugins/../../evil",
+ "ADMIN",
+ ]) {
+ const signed = signConnectState(
+ {
+ userId: "user-1",
+ serverId: "google-drive",
+ verifier: "v-1",
+ returnTo: hostile as "admin",
+ },
+ KEY,
+ NOW,
+ );
+ expect(readConnectState(signed, KEY, NOW)?.returnTo).toBe("settings");
+ }
+ });
+
+ 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",
+ );
+ });
+});
+
+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 a relative redirect 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("success returns to the account, on the app's origin", () => {
+ expect(
+ connectedAccountsUrlFor("http://localhost:3010", {
+ serverId: "google-drive",
+ }),
+ ).toBe("http://localhost:3010/settings/connected-accounts/google-drive");
+ });
+
+ test("success carries no outcome, because the account already says it", () => {
+ const url = connectedAccountsUrlFor("http://localhost:3010", {
+ serverId: "google-drive",
+ });
+ expect(url).not.toContain("?");
+ });
+
+ test("a server id is escaped rather than trusted into a path", () => {
+ // It comes off a signed state, so it is ours — but it lands in a URL, and a value that reaches a
+ // URL unescaped is one path traversal away from meaning something else.
+ expect(
+ connectedAccountsUrlFor("http://localhost:3010", {
+ serverId: "../../admin",
+ }),
+ ).toBe("http://localhost:3010/settings/connected-accounts/..%2F..%2Fadmin");
+ });
+
+ test("failure goes to the list, which is the screen that says so", () => {
+ /*
+ * The list, not an account page: the notice is drawn there, and on the paths where the state
+ * could not be read there is no server id to return to anyway. One outcome for every failure,
+ * because telling a forged state apart from an expired one only tells somebody probing this
+ * endpoint how far they got.
+ */
+ expect(
+ connectedAccountsUrlFor("http://localhost:3010", { failed: true }),
+ ).toBe(
+ "http://localhost:3010/settings/connected-accounts?connected=failed",
+ );
+ });
+
+ 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(
+ connectedAccountsUrlFor(undefined, { serverId: "google-drive" }),
+ ).toBe("/settings/connected-accounts/google-drive");
+ });
+
+ test("a connect started on the admin page returns to the admin page", () => {
+ // The round trip this removes: an administrator who connected from the connector's setup screen
+ // used to be put down on their personal settings page, mid-task, on another part of the app.
+ expect(
+ connectedAccountsUrlFor(
+ "http://localhost:3010",
+ { serverId: "google-drive" },
+ "admin",
+ ),
+ ).toBe("http://localhost:3010/admin/plugins/google-drive");
+ });
+
+ test("a failure goes to the list even when it began on the admin page", () => {
+ /*
+ * The admin route takes the server key in its path, and a failed state has no key to build one
+ * from — the whole reason a failure is anonymous is that the state could not be read. The list is
+ * also the only screen that draws the notice, so it is the honest destination either way.
+ */
+ expect(
+ connectedAccountsUrlFor(
+ "http://localhost:3010",
+ { failed: true },
+ "admin",
+ ),
+ ).toBe(
+ "http://localhost:3010/settings/connected-accounts?connected=failed",
+ );
+ });
+});
diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts
index 3349ab8e..bc84270a 100644
--- a/server/tests/plugin-store.integration.test.ts
+++ b/server/tests/plugin-store.integration.test.ts
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
-import { and, eq, sql } from "drizzle-orm";
+import { and, eq, inArray, sql } from "drizzle-orm";
import { createAuditStore } from "../src/audit";
import type { ActionPolicy } from "../src/computer/policy";
import { createDatabase } from "../src/db/client";
@@ -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"] };
@@ -46,6 +46,14 @@ let policy: ActionPolicy = { mode: "enforce", deny: [], allow: ["true"] };
* configured server, so it is removed only when the test is what created it.
*/
let serverWasAlreadyConfigured = false;
+/**
+ * Whether this deployment already advertised the tool this suite inserts.
+ *
+ * The vendor really does advertise `search_files`, so the row may be a refreshed fact about the
+ * vendor rather than the suite's fixture. Deleting by name regardless would take a real one; leaving
+ * it always would leave a fixture that reads on screen as a tool the vendor offers.
+ */
+let toolWasAlreadyAdvertised = false;
const store = createPluginStore({
database,
@@ -53,6 +61,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,
@@ -94,27 +110,60 @@ beforeAll(async () => {
.where(eq(mcpServers.id, serverId))
).length > 0;
+ toolWasAlreadyAdvertised =
+ (
+ await database
+ .select({ name: mcpTools.name })
+ .from(mcpTools)
+ .where(
+ and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)),
+ )
+ ).length > 0;
+
// The server row is written directly rather than through addServer, so the test needs no vendor
// to be reachable. What is under test is the decision, not the listing.
await database
.insert(mcpServers)
.values({
id: serverId,
- title: "Atlassian",
- vendor: "Atlassian",
- url: "https://mcp.atlassian.com/v1/mcp/authv2",
+ title: "Google Drive",
+ vendor: "Google",
+ url: "https://www.googleapis.com/drive/v3",
provenance: "first-party",
})
.onConflictDoNothing();
await database
.insert(mcpTools)
- .values({ serverId, name: toolName, description: "Search issues." })
+ .values({ serverId, name: toolName, description: "Search files." })
.onConflictDoNothing();
});
afterAll(async () => {
- await database.delete(pluginGrants).where(eq(pluginGrants.ref, ref));
+ /*
+ * Scoped to this suite's own Bots, never to the ref alone.
+ *
+ * `ref` names a REAL server and a real tool — `google-drive/search_files` — so a delete by ref
+ * matches every grant in the deployment, including the ones an administrator made for a Bot people
+ * use. This suite did exactly that once: it ran, and a Bot silently stopped being able to search
+ * Drive, with an audit row showing the grant had been made and nothing showing it removed.
+ *
+ * The primary key is (kind, ref, agent_id). Two of the three are not a row.
+ */
+ await database
+ .delete(pluginGrants)
+ .where(
+ and(
+ eq(pluginGrants.ref, ref),
+ inArray(pluginGrants.agentId, [holderId, strangerId]),
+ ),
+ );
// A server row is deployment configuration, so it belongs to the deployment rather than here.
+ // The fixture tool goes whether or not this suite owns the server, but only if it put it there.
+ if (!toolWasAlreadyAdvertised) {
+ await database
+ .delete(mcpTools)
+ .where(and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)));
+ }
if (!serverWasAlreadyConfigured) {
await database.delete(mcpTools).where(eq(mcpTools.serverId, serverId));
await database.delete(mcpServers).where(eq(mcpServers.id, serverId));
@@ -164,7 +213,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([]);
@@ -177,7 +226,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"],
};
@@ -198,7 +247,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);
@@ -206,14 +255,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 = {
@@ -236,7 +285,55 @@ 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");
+ });
+});
+
+describe("the trail says what happened, not what was permitted", () => {
+ /*
+ * THE REGRESSION THIS EXISTS FOR. `mcp.call_succeeded` used to be written before the credential
+ * was selected and before the network call, so a call that passed the grant and the policy and
+ * then failed left a row asserting it had succeeded — and nothing at all saying it had not.
+ *
+ * That is the worst arrangement available. A trail with a gap makes somebody go and look; a trail
+ * that is confidently wrong is used to rule the connector out and send the search elsewhere. It
+ * did exactly that: a Bot that could not read Drive at all had `call_succeeded` rows behind it.
+ *
+ * `search_files` on `google-drive` is reached as the asker, and nobody here has connected, so this
+ * call is permitted and then cannot be made — which is the shape of failure the row must show.
+ */
+ test("a call that is permitted and then fails is recorded as failed, not as succeeded", async () => {
+ await store.grant("mcp", ref, holderId, "admin@openbot.local");
+ const actorId = `trail_${suite}`;
+
+ await expect(
+ store.callTool({ ref, args: {}, botId: holderId, actorId }),
+ ).rejects.toBeInstanceOf(PluginRefusedError);
+
+ const mine = (await auditRowsFor(ref)).filter(
+ (row) => (row.payload as { actor?: string }).actor === actorId,
+ );
+
+ const failed = mine.filter((row) => row.eventType === "mcp.call_failed");
+ expect(failed.length).toBe(1);
+ // The reason travels with the row. For a 403 this is where the vendor names the API that is not
+ // enabled, which is the sentence that turns a guess into a fix.
+ expect((failed[0].payload as { failure?: string }).failure).toContain(
+ "connected",
+ );
+
+ // The point of the whole test: nothing claims this worked.
+ expect(
+ mine.filter((row) => row.eventType === "mcp.call_succeeded"),
+ ).toEqual([]);
});
});
@@ -274,8 +371,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");
});
});
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..f4adad2a
--- /dev/null
+++ b/server/tests/plugin-user-credential.integration.test.ts
@@ -0,0 +1,566 @@
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { randomUUID } from "node:crypto";
+import { and, eq, inArray, isNull } 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;
+/**
+ * The OAuth client this deployment had before the suite ran, restored afterwards.
+ *
+ * `mcp_servers.credential_id` is live configuration: on a database somebody uses, it points at the
+ * client an administrator registered. This suite has to repoint it to exercise the selection, and it
+ * used to leave it repointed at a credential the cleanup then deleted — so the connector afterwards
+ * reported "no OAuth client registered yet" and the administrator's own registration was gone.
+ *
+ * There is no foreign key to catch that: `mcp_servers.credential_id` is `text` against a `uuid`
+ * primary key, so the database will hold a pointer to a row that does not exist.
+ */
+let clientBefore: string | null = null;
+/**
+ * Whether this deployment already advertised the tool this suite inserts.
+ *
+ * The vendor really does advertise `search_files`, so deleting by name regardless would take a real
+ * row; never deleting leaves a fixture that reads on screen as a tool the vendor offers.
+ */
+let toolWasAlreadyAdvertised = 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;
+ },
+ // 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");
+ },
+ /*
+ * A real revocation, against this suite's own rows.
+ *
+ * It used to throw, on the same principle as `create` above: nothing in the suite revoked, so a
+ * call here meant the file had quietly started exercising something it did not claim to. That
+ * stopped being true when retirement arrived — revoking IS the thing under test now, and the
+ * assertion is that the vault row comes back revoked, which a stub cannot show.
+ *
+ * `create` still throws, because connections are still written directly.
+ */
+ revoke: async (id) => {
+ const [row] = await database
+ .update(credentials)
+ .set({ revokedAt: new Date() })
+ .where(eq(credentials.id, id))
+ .returning({ revokedAt: credentials.revokedAt });
+ if (!row?.revokedAt) throw new Error("credential was not revoked");
+ return row.revokedAt;
+ },
+ },
+ 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();
+ }
+
+ const [existing] = await database
+ .select({ id: mcpServers.id, credentialId: mcpServers.credentialId })
+ .from(mcpServers)
+ .where(eq(mcpServers.id, serverId));
+ serverWasAlreadyConfigured = existing !== undefined;
+ toolWasAlreadyAdvertised =
+ (
+ await database
+ .select({ name: mcpTools.name })
+ .from(mcpTools)
+ .where(
+ and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)),
+ )
+ ).length > 0;
+ clientBefore = existing?.credentialId ?? null;
+
+ // 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://www.googleapis.com/drive/v3",
+ 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 () => {
+ /*
+ * The suite's own two people, never every row for this vendor.
+ *
+ * `serverId` here is a real catalogue key, so a deployment somebody uses has real connections
+ * under it — and deleting by server id took a person's actual Drive connection with it, after
+ * their consent had succeeded. The user ids carry this run's random suffix, so nothing outside it
+ * can match.
+ */
+ await database
+ .delete(mcpUserCredentials)
+ .where(
+ and(
+ eq(mcpUserCredentials.serverId, serverId),
+ inArray(mcpUserCredentials.userId, [askerId, otherId]),
+ ),
+ );
+ /*
+ * Put the client pointer back before deleting anything, so a suite that borrowed live
+ * configuration leaves it as it found it. Ordered first on purpose: the deletes below remove the
+ * credential the row is currently pointing at.
+ *
+ * UNCONDITIONAL, and it used to be guarded by `serverWasAlreadyConfigured`. On a database that
+ * already had the server the guard was harmless, which is why this passed locally for as long as
+ * it did. On a fresh one — CI — the guard skipped, the column kept pointing at a credential this
+ * suite created, and the delete below was refused by the foreign key.
+ *
+ * The guard was hiding a real leak rather than avoiding one. Before that key existed the delete
+ * SUCCEEDED and left `mcp_servers.credential_id` addressing a row that no longer existed, which is
+ * exactly the dangling pointer that made a configured connector report having no client. Restoring
+ * it is right in both cases: `clientBefore` is null when there was no server to borrow from, which
+ * is what the column should say.
+ */
+ await database
+ .update(mcpServers)
+ .set({ credentialId: clientBefore })
+ .where(eq(mcpServers.id, serverId));
+ for (const id of credentialIds) {
+ await database.delete(credentials).where(eq(credentials.id, id));
+ }
+ /*
+ * Scoped to this suite's own Bot, never to the ref alone. `ref` is `google-drive/search_files`,
+ * a real server and a real tool, so a delete by ref alone removes an administrator's grants for
+ * Bots people use. See the same note in plugin-store.integration.test.ts, which this shares.
+ */
+ await database
+ .delete(pluginGrants)
+ .where(and(eq(pluginGrants.ref, ref), eq(pluginGrants.agentId, botId)));
+ // The fixture tool goes whether or not this suite owns the server, but only if it put it there.
+ if (!toolWasAlreadyAdvertised) {
+ await database
+ .delete(mcpTools)
+ .where(and(eq(mcpTools.serverId, serverId), eq(mcpTools.name, toolName)));
+ }
+ 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.
+ //
+ // A REAL credential row, where this used to write the string "a-deployment-credential". The
+ // column was `text` against a `uuid` primary key with no foreign key, so the database accepted a
+ // pointer to something that could not exist — and the test was therefore asserting against a
+ // state no deployment could reach, which is a weaker claim than it looked. It is now a genuine
+ // deployment credential that a fallback could really have spent.
+ const deploymentCredential = await registerClient();
+
+ await expect(
+ store.callTool({ ref, args: {}, botId, actorId: askerId }),
+ ).rejects.toThrow(PluginRefusedError);
+ expect(decrypted).toEqual([]);
+
+ // Repointed away before the credential is left behind, because the foreign key is `restrict`:
+ // the row cannot be deleted while this column addresses it, which is the point of the key.
+ await database
+ .update(mcpServers)
+ .set({ credentialId: null })
+ .where(eq(mcpServers.id, serverId));
+ expect(deploymentCredential).toBeTruthy();
+ });
+});
+
+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(
+ and(
+ eq(mcpUserCredentials.serverId, serverId),
+ inArray(mcpUserCredentials.userId, [askerId, otherId]),
+ ),
+ );
+
+ await expect(
+ store.callTool({ ref, args: {}, botId, actorId: askerId }),
+ ).rejects.toThrow(PluginRefusedError);
+ expect(decrypted).toEqual([]);
+ });
+});
+
+/*
+ * OFFBOARDING. "We removed their access" has to be true of the refresh token, not only of the
+ * session — the session is the half they cannot use, and the token is the half this deployment is
+ * still holding. For a per-person connector it is the first thing a customer asks about.
+ */
+describe("retiring the credentials a person owns", () => {
+ /*
+ * Counted as a property, not as a number.
+ *
+ * `connect` above writes vault rows directly and leaves earlier ones alone, so how many a person
+ * has accumulated depends on which tests ran first — and asserting an exact `retired` count would
+ * make these pass or fail on suite order rather than on behaviour. What has to be true is that
+ * nothing usable is left, which is the same claim however many there were.
+ *
+ * The product path does not accumulate them: `recordConnection` revokes the previous credential
+ * when it repoints the join row.
+ */
+ const unrevokedTokensFor = async (userId: string) =>
+ (
+ await database
+ .select({ id: credentials.id })
+ .from(credentials)
+ .where(
+ and(
+ eq(credentials.kind, "mcp_user_token"),
+ eq(credentials.keyId, userId),
+ isNull(credentials.revokedAt),
+ ),
+ )
+ ).length;
+
+ test("the vault stops holding a usable secret, and the connection is gone", async () => {
+ await registerClient();
+ const credentialId = await connect(askerId, askerRefreshToken);
+
+ // Reachable first, so what follows is an assertion about the retirement and not about setup.
+ await store.callTool({ ref, args: {}, botId, actorId: askerId });
+ expect(exchanged.at(-1)).toBe(askerRefreshToken);
+
+ await store.retireConnectionsFor(askerId, "admin@openbot.local");
+
+ const [credential] = await database
+ .select({ revokedAt: credentials.revokedAt })
+ .from(credentials)
+ .where(eq(credentials.id, credentialId));
+ expect(credential?.revokedAt).not.toBeNull();
+ expect(await unrevokedTokensFor(askerId)).toBe(0);
+
+ // The join row goes too, so no page claims a connection this deployment can no longer use.
+ expect(await store.connectionsFor(askerId)).toEqual([]);
+
+ // And a call is refused rather than answered from a revoked credential.
+ await expect(
+ store.callTool({ ref, args: {}, botId, actorId: askerId }),
+ ).rejects.toBeInstanceOf(PluginRefusedError);
+ });
+
+ /*
+ * THE ORPHAN. `mcp_user_credentials.user_id` cascades, so deleting a user row takes the join row
+ * with it and leaves the credential behind: unrevoked, referenced by nothing, reachable from no
+ * screen. Looking the owner up in the vault by `key_id` instead of through the join table is what
+ * makes it reachable at all, so that is asserted rather than assumed.
+ */
+ test("a credential orphaned by a deleted user row is still retired", async () => {
+ await registerClient();
+ const credentialId = await connect(otherId, otherRefreshToken);
+
+ // Exactly what the cascade leaves behind.
+ await database
+ .delete(mcpUserCredentials)
+ .where(eq(mcpUserCredentials.userId, otherId));
+
+ await store.retireConnectionsFor(otherId, "admin@openbot.local");
+
+ const [credential] = await database
+ .select({ revokedAt: credentials.revokedAt })
+ .from(credentials)
+ .where(eq(credentials.id, credentialId));
+ expect(credential?.revokedAt).not.toBeNull();
+ expect(await unrevokedTokensFor(otherId)).toBe(0);
+ });
+
+ test("retiring twice is quiet, and nobody owns nothing", async () => {
+ await registerClient();
+ await connect(askerId, askerRefreshToken);
+
+ expect(
+ (await store.retireConnectionsFor(askerId, "admin@openbot.local"))
+ .retired,
+ ).toBeGreaterThan(0);
+ // Already revoked is something an administrator can legitimately do twice.
+ expect(
+ (await store.retireConnectionsFor(askerId, "admin@openbot.local"))
+ .retired,
+ ).toBe(0);
+ // The empty actor is ANONYMOUS_ACTOR. It owns nothing, and must not match rows by being empty.
+ expect(
+ (await store.retireConnectionsFor("", "admin@openbot.local")).retired,
+ ).toBe(0);
+ });
+});
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([
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" });
});
});