diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b1e6a8afc..7582826b80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
## Unreleased
-**Before upgrading.** Five things change for an existing deployment:
+**Before upgrading.** Six things change for an existing deployment:
- A hosted computer's browser is now headed by default. It uses a little more memory than headless
did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old
behaviour.
@@ -31,6 +31,39 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
- Migration `0056_sign_in_code_step` widens the check on `sign_in_requests.status` to allow
`code_required` (below). A server and a computer must be upgraded together: an older computer has
no `/sign-in/code`.
+- Migration `0060_app_access_requests` adds one table, `app_access_requests`, where a person's
+ request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's
+ team account is kept, with who answered it and when. It starts empty and nothing is backfilled.
+
+### Each app on a Bot says whether you can use it, and you can ask an administrator for the rest
+
+A Bot's page now says, for every work app the Bot holds, what that app is for the person looking:
+ready, not connected yet, needing reconnecting, waiting on your organisation's shared account or on
+an administrator's approval of who the Bot reaches, not switched on or not finished setting up, or not
+given to this Bot. On somebody else's Team Bot it says whose account the app runs on: the owner's,
+which only the owner can reconnect, or your own, which the Bot asks to use the first time. Each comes with the one thing you can do about it —
+Connect or Reconnect your own account, or Request access where only an administrator can help — and
+apps are named by their own name ("Gmail", not "Composio Gmail"). In a conversation, a Bot that needs
+an app it cannot use now puts the same card in front of you instead of a sentence: an account you have
+not connected, a Shared app with no team account, an account the vendor no longer holds, and — through
+a new `show_app_access` tool every Bot holds — an app that is switched off or not granted to it. The
+card reads the app's current state when it is drawn, so it never offers a step already taken.
+Administrators see waiting requests at the top of Plugins, a count on each app's row, and a Requests
+section on the app's own page, where Approve switches the app on and grants the Bot the app's
+read-only actions in one press (or says what has to be done first; other actions are chosen on the
+Bot's page for the app) and Dismiss closes the request. Requests name who asked; nobody's connection
+status is shown to administrators. A person may have 20 requests waiting at once.
+
+A connection the vendor turns out to have lost now reads as lost everywhere once a call finds out: a
+Composio account the vendor no longer holds is removed so it can be connected again, and a Google
+Drive or Notion connection whose refresh token the vendor refuses is marked withdrawn, with Reconnect
+on the card instead of a tool error. A Memory source that fails for the same reasons says so in words
+rather than showing the card's marker.
+
+On a deployment with no `COMPOSIO_API_KEY`, Settings → Connected accounts no longer lists Composio
+apps, which nobody could connect there. An app's own page, reached from an older link, says it is not
+available yet and that an administrator has to finish setting it up, instead of naming the setting.
+The administrator's page for the app still names `COMPOSIO_API_KEY`.
### A new desktop install starts its server again
diff --git a/app/src/components/bot-profile/access.tsx b/app/src/components/bot-profile/access.tsx
index 14a8b8bed9..ca4a5ed8c6 100644
--- a/app/src/components/bot-profile/access.tsx
+++ b/app/src/components/bot-profile/access.tsx
@@ -3,6 +3,12 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { Fragment } from "react";
import { HandoffPanel } from "@/components/agents/handoff-panel";
+import {
+ AppAccessAction,
+ appAccessSentence,
+} from "@/components/plugins/app-access-action";
+import { PluginLogo } from "@/components/plugins/plugin-logo";
+import { Button } from "@/components/ui/button";
import {
PageEmpty,
PageRows,
@@ -19,6 +25,7 @@ import {
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import type { AgentProfile } from "@/lib/agents/queries";
+import { botAppsQueryOptions } from "@/lib/app-access/queries";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import { setPluginGrantMutationOptions } from "@/lib/plugins/mutations";
import {
@@ -29,7 +36,12 @@ import {
import { readToolName } from "@/lib/plugins/tool-name";
import { queryClient } from "@/query-client";
-/** "google-drive" as "Google Drive": the connector key, said the way a person would. */
+/**
+ * "google-drive" as "Google Drive": the connector key, said the way a person would.
+ *
+ * ONLY FOR A CONNECTOR THE APP-ACCESS READ DID NOT NAME. Every app it names is drawn by its own
+ * display name — "Gmail", where this would have said "Composio Gmail".
+ */
function connectorName(key: string): string {
return key
.split(/[-_]/)
@@ -147,13 +159,26 @@ function YourSkills({ agentId }: { agentId: string }) {
}
/**
- * Everything this Bot holds, read from the same snapshot a run is offered. Read-only: apps are
- * granted by an administrator, who gets a link to where that is decided.
+ * Everything this Bot holds, read from the same snapshot a run is offered, and what each app is for
+ * the person looking.
+ *
+ * READ-ONLY FOR GRANTS, NOT FOR THE PERSON'S OWN PART. Apps are granted by an administrator, who gets
+ * a link to where that is decided. But a Bot that holds Gmail is no use to somebody who has not
+ * connected their Gmail, and this list used to say "Gmail · 4 tools" to everybody alike — so each
+ * app now says what it is for the person reading (`GET /api/plugins/for/:agentId/apps`) with the one
+ * thing they can do about it: Connect, Reconnect, Request access, or Requested. The state is the
+ * server's and is drawn as it comes; see `AppAccessAction`.
+ *
+ * ADMINISTRATORS SEE THEIR OWN STATE HERE TOO, AND NOBODY ELSE'S. The app-access read answers for
+ * whoever is asking, exactly as the connections read does, so this cannot become a list of who has
+ * connected what.
*/
function Granted({ agentId }: { agentId: string }) {
const me = useQuery(currentUserQueryOptions()).data;
const plugins = useQuery(agentPluginsQueryOptions(agentId));
- if (plugins.isPending) return null;
+ const apps = useQuery(botAppsQueryOptions(agentId));
+ // Both, so no row is drawn without its state and then redrawn with one a moment later.
+ if (plugins.isPending || apps.isPending) return null;
if (!plugins.data) {
return (
);
}
- const connectors = [...connectorTools(plugins.data).entries()];
+ const admin = me?.role === "admin";
+ const tools = connectorTools(plugins.data);
+ const states = apps.data ?? [];
+ /*
+ * Every app the Bot holds a grant for, by the app's own name, and then any connector the grant
+ * snapshot names that the app-access read did not — which is none on a healthy deployment, and
+ * keeps a row from vanishing on one where the second read failed.
+ */
+ const described = new Set(states.map((app) => app.key));
+ const rest = [...tools.entries()].filter(([key]) => !described.has(key));
const skills = plugins.data.skills;
+ const count = states.length + rest.length + skills.length;
return (
- {connectors.length === 0 && skills.length === 0 ? (
+ {apps.error ? (
+
+ Whether each app is ready for you could not be loaded.
+
+ ) : null}
+ {count === 0 ? (
Nothing granted yet. An administrator grants apps from the Plugins
screens; until then this Bot can converse, and nothing more.
) : (
- {connectors.map(([key, labels], index) => (
+ {states.map((app, index) => {
+ const labels = tools.get(app.key) ?? [];
+ return (
+
+ {index > 0 ? : null}
+
+
+
+
+
+ {app.displayName}
+ {/*
+ * Unclamped: the sentence ends on who can fix it, and two lines at a phone's
+ * width cut exactly that off.
+ */}
+
+ {app.state === "ready"
+ ? [appAccessSentence(app)]
+ .concat(labels.length > 0 ? [toolList(labels)] : [])
+ .join(" ")
+ : appAccessSentence(app)}
+
+
+
+ {app.state === "ready" ? (
+
+ ) : (
+
+ )}
+ {admin ? (
+
+ ) : null}
+
+
+
+ );
+ })}
+ {rest.map(([key, labels], index) => (
- {index > 0 ? : null}
-
- ) : undefined
- }
- size="sm"
- >
+ {states.length + index > 0 ? : null}
+ {connectorName(key)}
-
- {labels.slice(0, 4).join(", ")}
- {labels.length > 4 ? ` and ${labels.length - 4} more` : ""}
-
+ {toolList(labels)}
-
- {labels.length} {labels.length === 1 ? "tool" : "tools"}
-
- {me?.role === "admin" ? (
-
+
+ {admin ? (
+
) : null}
@@ -212,7 +282,7 @@ function Granted({ agentId }: { agentId: string }) {
))}
{skills.map((skill, index) => (
- {connectors.length + index > 0 ? : null}
+ {states.length + rest.length + index > 0 ? : null}
@@ -232,3 +302,49 @@ function Granted({ agentId }: { agentId: string }) {
);
}
+
+/** The first few tool labels, and how many more. */
+function toolList(labels: string[]): string {
+ return `${labels.slice(0, 4).join(", ")}${labels.length > 4 ? ` and ${labels.length - 4} more` : ""}`;
+}
+
+function ToolCount({ count }: { count: number }) {
+ return (
+
+ {count} {count === 1 ? "tool" : "tools"}
+
+ );
+}
+
+/**
+ * Where an administrator decides which of the app's tools this Bot holds.
+ *
+ * A CHEVRON OF ITS OWN RATHER THAN THE WHOLE ROW. The row used to be the link, which was right while
+ * it held nothing else; now it can hold Connect or Request access, and a button inside a link is two
+ * controls answering one click. The destination is unchanged.
+ */
+function ManageLink({
+ agentId,
+ appKey,
+ title,
+}: {
+ agentId: string;
+ appKey: string;
+ title: string;
+}) {
+ return (
+
+ }
+ size="icon-sm"
+ variant="ghost"
+ >
+
+
+ );
+}
diff --git a/app/src/components/channels/app-access-card.tsx b/app/src/components/channels/app-access-card.tsx
new file mode 100644
index 0000000000..d58164cdf9
--- /dev/null
+++ b/app/src/components/channels/app-access-card.tsx
@@ -0,0 +1,149 @@
+import { useQuery } from "@tanstack/react-query";
+import {
+ AppAccessAction,
+ appAccessSentence,
+} from "@/components/plugins/app-access-action";
+import { PluginLogo } from "@/components/plugins/plugin-logo";
+import { type AppAccess, botAppQueryOptions } from "@/lib/app-access/queries";
+import { useDeclaredBotId } from "@/lib/copilot/active-bot";
+import type { ConnectPrompt } from "@openbot/shared/connect-prompt";
+import { ConnectAppCard } from "./connect-app-card";
+
+/**
+ * A work app a Bot could not use, in front of the person, with the one thing that would let it.
+ *
+ * WHAT DRAWS ONE. Every way an app is out of reach for a call now reaches the transcript carrying
+ * the same marker (`@openbot/shared/connect-prompt`): a person who has not connected, a Shared app
+ * with no team account, an account the vendor has lost, and — with no call refused at all — the
+ * `show_app_access` tool a Bot calls for an app it holds nothing for, switched off or not granted.
+ * They used to end in three different places: a card with Connect, a sentence telling the person to
+ * ask an administrator, and a sentence naming a settings page.
+ *
+ * LIVE, NEVER WHAT THE REFUSAL SAID. The marker names an app and a Bot; the state is asked of the
+ * server for this person on that Bot when the card is drawn, and the shared app-access piece draws
+ * that with the button that goes with it. A card scrolled back to an hour later says what is true
+ * now — connected since, granted since, the request approved — and never offers a step that has
+ * already been taken. Every write those buttons make refreshes the same key, so the card follows.
+ *
+ * THE ONE PLACE THE REFUSAL WINS. The call that finds an account gone at the vendor deletes its row,
+ * so the live state normally says so itself. A refusal from before that, over a row that still
+ * stands, reads `ready` live — and this card is drawn over exactly the call that failed. So a
+ * `reconnect` or `shared` refusal over a live `ready` is drawn as the refusal says — unless the
+ * account was connected after the refusal, which is the reconnection. Anything else live is newer
+ * than the refusal and is drawn as it is.
+ */
+export function AppAccessCard({ prompt }: { prompt: ConnectPrompt }) {
+ const declared = useDeclaredBotId();
+ const agentId = prompt.agentId ?? declared;
+ /*
+ * A refusal from before refusals named their Bot, on a surface that declared none: there is no Bot
+ * to ask about, so the card it was drawn as then is the card it is drawn as now.
+ */
+ if (!agentId) return ;
+ return ;
+}
+
+function LiveAppAccessCard({
+ agentId,
+ prompt,
+}: {
+ agentId: string;
+ prompt: ConnectPrompt;
+}) {
+ const live = useQuery(botAppQueryOptions(agentId, prompt.serverId));
+ /*
+ * A read that failed falls back to what this card was before it read anything, where that was a
+ * card at all: a person's own missing connection still gets its Connect button. The other reasons
+ * had only ever been a sentence, and the sentence is what is left.
+ */
+ if (live.error && (prompt.reason ?? "connect") === "connect")
+ return ;
+ const access = live.data ? asRefused(live.data, prompt) : null;
+ const name = access?.displayName ?? prompt.title;
+ return (
+
+
+
+
+
+
+
{name}
+ {/*
+ * Nothing while the read is open — no placeholder, per the data-access rule — and a
+ * sentence of the card's own if it failed. NOT the refusal's message: that was written for
+ * the model ("do not browse to its website"), and reads as an instruction to the wrong
+ * reader. Otherwise the state, which is the server's.
+ */}
+ {live.isPending ? null : access ? (
+
+ {access.state === "ready" && prompt.reason !== "access"
+ ? `${name} is ready now. Ask again and the Bot can use it.`
+ : refusedSentence(access, prompt)}
+
+ ) : (
+
+ {name}'s status couldn't be loaded. Try again in a moment.
+
+ )}
+
+
+ {access && access.state !== "ready" ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * The live state, or the refusal's where only the refusal can know: an account the vendor lost,
+ * which the server still reads as `ready`. See the card's own comment.
+ *
+ * A lost team account is drawn as not connected with NO request to file — the server would answer
+ * any request 409, since as far as it knows the account is fine — so the sentence says whose move it
+ * is and the button is left out rather than offered and refused.
+ *
+ * A lost account on somebody else's Team Bot keeps the live `accountHolder`. Where that is the
+ * owner's account, the override reads as the owner's reconnection — the sentence says only the owner
+ * can, and `AppAccessAction` offers nothing — because a Reconnect pressed here would connect this
+ * person's own account, which the Bot does not run on.
+ */
+function asRefused(live: AppAccess, prompt: ConnectPrompt): AppAccess {
+ if (live.state !== "ready") return live;
+ /*
+ * Connected again since the refusal: that is the reconnection, and the live `ready` is true.
+ * Compared as instants rather than as strings, because the two come from different clocks'
+ * formatters and only the instant is the same.
+ */
+ if (
+ prompt.at &&
+ live.connectedAt &&
+ Date.parse(live.connectedAt) > Date.parse(prompt.at)
+ )
+ return live;
+ if (prompt.reason === "reconnect") return { ...live, state: "reconnect" };
+ if (prompt.reason === "shared")
+ return {
+ ...live,
+ state: "shared_not_connected",
+ requestKind: null,
+ request: null,
+ };
+ return live;
+}
+
+/** As the shared sentence, except a lost team account, which needs reconnecting rather than connecting. */
+function refusedSentence(access: AppAccess, prompt: ConnectPrompt): string {
+ if (
+ access.state === "shared_not_connected" &&
+ prompt.reason === "shared" &&
+ !access.requestKind
+ )
+ return `Your organisation's ${access.displayName} account needs reconnecting. Only an administrator can reconnect it.`;
+ return appAccessSentence(access);
+}
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index 94a8972d20..6cf6cc805d 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -59,7 +59,10 @@ import {
toolResultFailed,
} from "@/lib/plugins/tool-result";
import { readTeamBotConsent } from "@/lib/team-bots";
-import { readConnectPrompt } from "@openbot/shared/connect-prompt";
+import {
+ readConnectPrompt,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
import { cn } from "@/lib/utils";
import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive";
import {
@@ -74,7 +77,7 @@ import {
toVisibleChatItems,
} from "./chat-messages";
import type { QueuedMessage } from "./composer";
-import { ConnectAppCard } from "./connect-app-card";
+import { AppAccessCard } from "./app-access-card";
import { TeamBotConsentCard } from "./team-bot-consent";
import { ToolRenderBoundary } from "./tool-boundary";
import { ToolLine } from "./tool-line";
@@ -1351,9 +1354,18 @@ const TranscriptToolCall = memo(function TranscriptToolCall({
*
* Named from the reader's side: what was done, against which server, with the server's own words
* behind a disclosure. The identifier the model was offered never reaches the screen.
+ *
+ * Exported for its tests, which draw it without the CopilotKit provider `TranscriptToolCall` needs.
*/
-function ServerToolLine({ name, result }: { name: string; result?: string }) {
- const { label, detail } = readToolName(name);
+export function ServerToolLine({
+ name,
+ result,
+}: {
+ name: string;
+ result?: string;
+}) {
+ const running = result === undefined;
+ const { label, detail } = readToolName(name, running);
/*
* A refusal is not a result, and must not read like one.
*
@@ -1374,20 +1386,36 @@ function ServerToolLine({ name, result }: { name: string; result?: string }) {
// A Team Bot asking to use this person's own account draws its consent card, not the sentence.
const consent = refused ? readTeamBotConsent(body) : null;
if (consent) return ;
- // An app this person has not connected draws the connect button, not the name of a screen.
- const connect = refused ? readConnectPrompt(body) : null;
- if (connect) return ;
+ /*
+ * An app this call could not use draws a card with the step that fixes it, not the name of a
+ * screen: a refusal carrying the marker, or the answer of the one tool whose whole job is to put
+ * that card on screen. Only that tool's ordinary answer is read for a marker — any other result
+ * is somebody else's text, and a card must not be conjured out of it.
+ */
+ const card = refused
+ ? readConnectPrompt(body)
+ : name === SHOW_APP_ACCESS_TOOL
+ ? readConnectPrompt(answer)
+ : null;
+ if (card) return ;
+ /*
+ * THAT TOOL'S OTHER ANSWERS ARE FOR THE MODEL. An app nobody can name comes back as plain text
+ * telling the Bot to say so plainly and not to browse to it — an instruction, which read by the
+ * person looks like the Bot being told off in front of them. The line still says the check
+ * happened; the Bot's own reply says what it found.
+ */
+ const shown = name === SHOW_APP_ACCESS_TOOL ? undefined : body;
return (
- {body ? (
+ {shown ? (
- {forDisplay(body)}
+ {forDisplay(shown)}
) : null}
diff --git a/app/src/components/channels/connect-app-card.tsx b/app/src/components/channels/connect-app-card.tsx
index 89f712d54d..56361a9e84 100644
--- a/app/src/components/channels/connect-app-card.tsx
+++ b/app/src/components/channels/connect-app-card.tsx
@@ -1,8 +1,8 @@
-import { useMutation, useQuery } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef } from "react";
+import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab";
import { Button } from "@/components/ui/button";
-import { connectAccountMutationOptions } from "@/lib/plugins/mutations";
import {
connectionsQueryOptions,
personalConnections,
@@ -21,6 +21,11 @@ import { queryClient } from "@/query-client";
* WATCHING, NOT TRUSTING. The return trip proves nothing — it is an ordinary redirect in a tab this
* one cannot read — so nothing here treats "they came back" as "it worked". The card polls this
* person's own connections while it waits and only says connected when the server lists one.
+ *
+ * NOW THE FALLBACK RATHER THAN THE CARD. A refusal names the Bot it came from and `AppAccessCard`
+ * draws it from the app's live state for this person on that Bot. This one is drawn only where no
+ * Bot can be named — a refusal written before refusals carried one, on a surface that declared none
+ * — and it can only offer Connect, which is all such a refusal ever meant.
*/
export function ConnectAppCard({
serverId,
@@ -31,12 +36,15 @@ export function ConnectAppCard({
title: string;
message: string;
}) {
- const [waiting, setWaiting] = useState(false);
- /** This app takes a key somebody types, so there is no page to send them to. */
- const [needsKey, setNeedsKey] = useState(false);
- /** The authorization url, when the browser refused to open the tab holding it. */
- const [blockedUrl, setBlockedUrl] = useState(null);
- const connect = useMutation(connectAccountMutationOptions());
+ const {
+ open: openTab,
+ waiting,
+ stopWaiting,
+ needsKey,
+ blockedUrl,
+ pending,
+ error,
+ } = useConnectInNewTab();
const connections = useQuery({
...connectionsQueryOptions(),
// Only while a consent is open in the other tab; otherwise this is an ordinary cached read.
@@ -59,53 +67,13 @@ export function ConnectAppCard({
const was = useRef(connected);
useEffect(() => {
if (connected && !was.current) {
- setWaiting(false);
+ stopWaiting();
void queryClient.invalidateQueries();
}
was.current = connected;
- }, [connected]);
-
- /**
- * The tab, opened on the click itself rather than on the answer to it.
- *
- * A BROWSER ONLY LETS A PAGE OPEN A TAB WHILE A CLICK IS STILL RECENT. Chrome's transient
- * activation lasts about five seconds, and the request that mints the authorization url has to
- * finish first — so on a slow deployment `window.open` was called with the activation already
- * expired, the tab was blocked, and the card cheerfully said to finish in a tab that did not
- * exist. The blank tab is claimed first and navigated once the url arrives.
- *
- * `noopener` CANNOT BE PASSED HERE: it makes `window.open` answer null, and null is a handle
- * nothing can navigate later. The opener is severed on the handle instead, which is the same
- * guarantee by a different route.
- */
- const claimTab = (): Window | null => {
- const tab = window.open("", "_blank");
- if (tab) tab.opener = null;
- return tab;
- };
+ }, [connected, stopWaiting]);
- const open = () => {
- const tab = claimTab();
- connect.mutate(serverId, {
- onSuccess: (authorizationUrl) => {
- /*
- * No url means this app is connected by typing a key rather than by visiting a page — the
- * route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page
- * that has the form, beats a button that silently does nothing.
- */
- if (!authorizationUrl) {
- tab?.close();
- setNeedsKey(true);
- return;
- }
- setWaiting(true);
- if (tab) tab.location.replace(authorizationUrl);
- // The tab was blocked. The url is still good, so offer it as something to click.
- else setBlockedUrl(authorizationUrl);
- },
- onError: () => tab?.close(),
- });
- };
+ const open = () => openTab(serverId);
return (
{/* The heading above already names the app; the button says what pressing it does. */}
-
diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx
index 22431443b4..c03e5d2870 100644
--- a/app/src/components/channels/tool-activity.tsx
+++ b/app/src/components/channels/tool-activity.tsx
@@ -10,7 +10,7 @@ import { useCallback, useContext, useMemo, useSyncExternalStore } from "react";
import { Streamdown } from "streamdown";
import { PendingApprovals } from "@/components/approvals/inline-approval";
import { markdownComponents } from "@/lib/markdown";
-import { humanise, readToolName } from "@/lib/plugins/tool-name";
+import { humanise, ownToolLabel, readToolName } from "@/lib/plugins/tool-name";
import {
asText,
forDisplay,
@@ -18,7 +18,10 @@ import {
toolResultFailed,
} from "@/lib/plugins/tool-result";
import { readTeamBotConsent } from "@/lib/team-bots";
-import { readConnectPrompt } from "@openbot/shared/connect-prompt";
+import {
+ readConnectPrompt,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
import { researchRoleOfTool } from "@openbot/shared/research";
import { browserStepDetails, isBrowserStep } from "./browser-activity";
import type { VisibleChatItem } from "./chat-messages";
@@ -78,6 +81,12 @@ function foldable(item: ToolItem, isDrawn: (name: string) => boolean): boolean {
if (isBrowserStep(item)) return true;
const name = item.toolCall.function.name;
if (!name.startsWith("mcp__") && isDrawn(name)) return false;
+ /*
+ * The app-access card is drawn from a server tool's answer, so no renderer registers it and the
+ * registry cannot say so. Never folded — not even while its answer is still on the way, or the
+ * card would land inside a group and then jump out of it.
+ */
+ if (name === SHOW_APP_ACCESS_TOOL) return false;
return !asksThePerson(item.result);
}
@@ -116,7 +125,7 @@ export function useDrawnTool(): (name: string) => boolean {
* Whether this refusal is a question for the person rather than a line about what a Bot did.
*
* Both of these draw a card with something to press — a Team Bot asking to act as them, and an app
- * they have not connected — and neither may sit behind a disclosure for the same reason: a question
+ * they cannot use yet (not connected, lost at the vendor, or a team account nobody connected) — and neither may sit behind a disclosure for the same reason: a question
* nobody can see is a conversation that has stopped without saying so.
*/
function asksThePerson(result: string | undefined): boolean {
@@ -228,17 +237,25 @@ export function stepDetails(step: ToolItem): StepDetails {
const answer = step.result === undefined ? undefined : asText(step.result);
const refused = answer?.startsWith(REFUSAL_MARKER) ?? false;
const body = refused ? answer?.slice(REFUSAL_MARKER.length).trim() : answer;
+ // This deployment's own tools answer the model, not the person; see `ServerToolLine`.
+ const own = ownToolLabel(name) !== null;
const outcome = {
id: step.id,
pending: step.result === undefined,
failed: !refused && toolResultFailed(step.result),
refused,
inputs,
- ...(body ? { output: body } : {}),
+ ...(body && !own ? { output: body } : {}),
};
- // Not a connector: a tool an external agent runs itself. Named in words, never as an identifier.
+ // Not a connector: one of this deployment's own tools, or one an external agent runs itself.
+ // Named in words, never as an identifier.
if (!name.startsWith("mcp__"))
- return { ...outcome, kind: "tool", label: humanise(name), pages: 0 };
+ return {
+ ...outcome,
+ kind: "tool",
+ label: ownToolLabel(name, outcome.pending) ?? humanise(name),
+ pages: 0,
+ };
const [, server = "", ...rest] = name.split("__");
const role = researchRoleOfTool(server, rest.join("__"));
diff --git a/app/src/components/plugins/access-requests.tsx b/app/src/components/plugins/access-requests.tsx
new file mode 100644
index 0000000000..973ba8f565
--- /dev/null
+++ b/app/src/components/plugins/access-requests.tsx
@@ -0,0 +1,196 @@
+import { IconInbox } from "@tabler/icons-react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { Fragment } from "react";
+import { PageRows, PageSection } from "@/components/layout/page-shell";
+import { Button } from "@/components/ui/button";
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemFooter,
+ ItemMedia,
+ ItemTitle,
+} from "@/components/ui/item";
+import { Separator } from "@/components/ui/separator";
+import {
+ approveAppAccessRequestMutationOptions,
+ dismissAppAccessRequestMutationOptions,
+} from "@/lib/app-access/mutations";
+import {
+ type AppAccessRequest,
+ appAccessInboxQueryOptions,
+} from "@/lib/app-access/queries";
+import { relativeTime } from "@/lib/relative-time";
+import { queryClient } from "@/query-client";
+
+/**
+ * The requests people have filed for an administrator to answer: switch an app on, give it to a
+ * Bot, or connect its shared account.
+ *
+ * ONE SECTION, ON TWO SCREENS. An app's own admin page lists the requests about that app, because
+ * that is where an administrator is already deciding about it. The Plugins list carries all of them,
+ * because an app nobody has switched on has no page of its own to list them on — a Composio app
+ * enabled by approving the request is exactly that case — and because a request nobody sees is a
+ * person waiting for nothing.
+ *
+ * APPROVE DOES THE THING. The server switches the app on and grants it to the Bot in the same press,
+ * or refuses with the step only an administrator can take first — a token to paste, a team account
+ * to connect — and the request stays pending. That sentence is drawn on the row it is about, because
+ * the screen's own banner is a long way from the button that was pressed.
+ *
+ * A GRANT IS READ-ONLY UNTIL SOMEBODY CHOOSES MORE. One press is not a decision about every action an
+ * app offers — Gmail's include sending and deleting mail — so approving gives the Bot the app's
+ * read-only actions and nothing else, and an app with none is refused with a sentence pointing at the
+ * Bot's page. The row says so before the press, and links to that page where it can.
+ *
+ * REQUESTS, NOT CONNECTIONS. Each row names who asked, because they asked; nothing here says who has
+ * connected what, which stays private to each person (see the note on `admin/plugins/$key.tsx`).
+ *
+ * Nothing at all while the read is open or when nothing is waiting: an empty section on every app's
+ * page would teach an administrator to scroll past the heading.
+ */
+export function AccessRequestsSection({ app }: { app?: string }) {
+ const inbox = useQuery(appAccessInboxQueryOptions(true, app));
+ if (inbox.isPending) return null;
+ if (inbox.error)
+ return (
+
+
+ Access requests could not be loaded.
+
+
+ );
+ const requests = inbox.data?.requests ?? [];
+ if (requests.length === 0) return null;
+ return (
+
+
+ {requests.map((request, index) => (
+
+ {index > 0 ? : null}
+
+
+ ))}
+
+
+ );
+}
+
+/** What the request asks for, as the thing an administrator would do. */
+export function askedFor(request: AppAccessRequest): string {
+ const bot = request.botName ?? "a Bot";
+ switch (request.kind) {
+ case "enable":
+ return `Turn on ${request.appTitle} for ${bot}`;
+ case "grant":
+ return `Give ${request.appTitle} to ${bot}`;
+ case "connect_shared":
+ return `Connect the shared ${request.appTitle} account`;
+ }
+}
+
+function AccessRequestRow({ request }: { request: AppAccessRequest }) {
+ // One pair per row, so a refusal is drawn under the request it is about and nowhere else.
+ const approve = useMutation(
+ approveAppAccessRequestMutationOptions(queryClient),
+ );
+ const dismiss = useMutation(
+ dismissAppAccessRequestMutationOptions(queryClient),
+ );
+ const busy = approve.isPending || dismiss.isPending;
+ const error = approve.error ?? dismiss.error;
+ const who = request.requesterName ?? request.requesterEmail ?? "Somebody";
+ return (
+
+
+
+
+
+ {askedFor(request)}
+
+ {who} asked {relativeTime(request.createdAt)}
+ {request.kind === "connect_shared" && request.botName
+ ? `, for ${request.botName}`
+ : ""}
+ .
+
+
+
+ {
+ approve.reset();
+ dismiss.mutate(request.id);
+ }}
+ size="sm"
+ variant="ghost"
+ >
+ {dismiss.isPending ? "Dismissing…" : "Dismiss"}
+
+ {
+ dismiss.reset();
+ approve.mutate({ id: request.id });
+ }}
+ size="sm"
+ >
+ {approve.isPending ? "Approving…" : "Approve"}
+
+
+ {error ? (
+
+
+ {error.message}
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * What approving gives a Bot, for a request that grants one: the app's read-only actions, with the
+ * rest chosen on the Bot's page under the app. Linked there for a grant, whose app is already on
+ * and so has that page; an app still to be switched on has none until the press that switches it on.
+ */
+function GrantNote({ request }: { request: AppAccessRequest }) {
+ if (request.kind === "connect_shared" || !request.agentId) return null;
+ const bot = request.botName ?? "the Bot";
+ const where = `the Bot's page under ${request.appTitle}`;
+ const page =
+ request.kind === "grant" ? (
+
+ {where}
+
+ ) : (
+ where
+ );
+ return (
+ <>
+ Approving gives {bot} only {request.appTitle}'s read-only actions. Choose
+ any others on {page}.
+ >
+ );
+}
+
+/**
+ * How many requests are waiting, for a list row that wants to say so: one app's, or all of them.
+ * Null while nothing is waiting or nothing has been read, so a row draws nothing rather than "0".
+ */
+export function usePendingRequestCounts() {
+ const inbox = useQuery(appAccessInboxQueryOptions(true));
+ return inbox.data?.counts ?? null;
+}
diff --git a/app/src/components/plugins/app-access-action.tsx b/app/src/components/plugins/app-access-action.tsx
new file mode 100644
index 0000000000..df9e2e9d37
--- /dev/null
+++ b/app/src/components/plugins/app-access-action.tsx
@@ -0,0 +1,308 @@
+import { useMutation } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { useEffect, useRef } from "react";
+import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab";
+import { Button } from "@/components/ui/button";
+import {
+ cancelAppAccessRequestMutationOptions,
+ requestAppAccessMutationOptions,
+} from "@/lib/app-access/mutations";
+import { type AppAccess, appAccessKeys } from "@/lib/app-access/queries";
+import { queryClient } from "@/query-client";
+
+/**
+ * One work app's state for the person looking, and the one thing they can do about it.
+ *
+ * ONE PIECE, DRAWN IN TWO PLACES. A Bot's page lists every app the Bot holds with a row each, and a
+ * conversation draws a card when the Bot needed an app it could not use. Both say the same thing —
+ * whether this person can use this app through this Bot, whose move it is if not, and the button
+ * for that move — so both draw it from here, and a sentence corrected for one is corrected for both.
+ *
+ * WHO CAN FIX IT IS THE WHOLE SENTENCE. Seven states reduce to four answers: nothing to do, a step
+ * that is yours (connect, reconnect), a step only an administrator can take (switch it on, finish
+ * setting it up, grant it, connect or approve the team account), and on somebody else's Team Bot a
+ * step only its owner can take (reconnect the account the Bot runs on). The copy names which,
+ * because "Gmail is unavailable" sends somebody hunting through their own settings for a switch that
+ * is not there.
+ *
+ * THE STATE IS THE SERVER'S. It arrives on `access` and is drawn as it is; nothing here works out
+ * from `granted` or `accountMode` what the state ought to be. The one exception is the caller's: a
+ * card may hand in an `access` whose state it overrode from the refusal it was drawn for — see
+ * `AppAccessCard` — and that is the caller's decision, made where the refusal is in hand.
+ */
+
+/** What the person is told about one app, in words that say whose move it is. */
+export function appAccessSentence(access: AppAccess): string {
+ const name = access.displayName;
+ const owners = access.accountHolder === "owner";
+ switch (access.state) {
+ case "ready":
+ if (owners)
+ return `Ready to use. This Bot uses its owner's ${name} account.`;
+ if (access.asksFirst)
+ return `Ready to use with your own ${name} account. You'll be asked to allow it the first time.`;
+ return "Ready to use.";
+ case "not_connected":
+ return `You haven't connected your ${name} account yet.`;
+ case "reconnect":
+ return owners
+ ? `This Bot uses its owner's ${name} account, which needs reconnecting. Only the Bot's owner can reconnect it.`
+ : `Your ${name} connection needs reconnecting.`;
+ case "shared_not_connected":
+ return `Your organisation's ${name} account isn't connected yet. Only an administrator can connect it.`;
+ case "shared_not_approved":
+ return `This Bot reaches more people than an administrator has approved for your organisation's ${name} account. Only an administrator can approve it.`;
+ case "disabled":
+ return disabledSentence(access);
+ case "not_granted":
+ return `This Bot hasn't been given ${name}. Only an administrator can give it.`;
+ }
+}
+
+/**
+ * Switched off, in the words for why. "Turned off by an administrator" was true of one reason in
+ * five: an app nobody switched on, and a set-up somebody has not finished, are not a decision
+ * anybody took — and the Composio case says what Settings → Connected accounts says about the same
+ * app, so the two screens never disagree about it.
+ */
+function disabledSentence(access: AppAccess): string {
+ const name = access.displayName;
+ switch (access.unavailable) {
+ case "composio_unconfigured":
+ return `${name} is not available on this OpenBot yet. An administrator has to finish setting it up.`;
+ case "scheme_unreadable":
+ case "oauth_client_missing":
+ case "token_missing":
+ return `${name} isn't fully set up yet. Only an administrator can finish setting it up.`;
+ default:
+ return `${name} isn't switched on for this OpenBot. Only an administrator can switch it on.`;
+ }
+}
+
+/**
+ * The button for the person's next move, and what it says while it happens.
+ *
+ * - `ready` — nothing.
+ * - `shared_not_approved` — nothing either: the call that was refused already filed the
+ * administrator's request, so there is no second one to make.
+ * - `reconnect` on an account the Bot's owner holds — nothing. Only the owner can reconnect their
+ * own account, and nobody else's press could do it for them.
+ * - `not_connected` / `reconnect` — the person's own connection. Connect starts it in a new tab
+ * with the connect route Settings presses; an app connected with a typed key, and a Composio app
+ * that already has a row (which the connect route refuses until it is disconnected), go to the
+ * app's own page in Settings instead, where the form and Disconnect are.
+ * - the three an administrator fixes — Request access, which files the request the server decides
+ * the kind of, and then Requested with a way to withdraw it.
+ */
+export function AppAccessAction({
+ agentId,
+ access,
+}: {
+ agentId: string;
+ access: AppAccess;
+}) {
+ if (access.state === "ready" || access.state === "shared_not_approved")
+ return null;
+ if (access.state === "reconnect" && access.accountHolder === "owner")
+ return null;
+ if (access.state === "not_connected" || access.state === "reconnect")
+ return ;
+ return ;
+}
+
+/** The person's own account: connect it, or reconnect it. */
+function ConnectAction({ access }: { access: AppAccess }) {
+ const label = access.state === "reconnect" ? "Reconnect" : "Connect";
+ const tab = useConnectInNewTab();
+ const { waiting, stopWaiting } = tab;
+
+ /*
+ * WATCHING THE SERVER WHILE A CONSENT IS OPEN in the other tab, because the return trip proves
+ * nothing. The whole app-access view is refreshed every two seconds until this app stops being
+ * this person's to connect — which is what unmounts this component — and then everything once,
+ * because the grants a Bot is offered are read per conversation and the turn after this has to
+ * be asked against a store that knows about the new account.
+ */
+ useEffect(() => {
+ if (!waiting) return;
+ const timer = setInterval(() => {
+ void queryClient.invalidateQueries({ queryKey: appAccessKeys.all });
+ }, 2_000);
+ return () => clearInterval(timer);
+ }, [waiting]);
+ const waited = useRef(false);
+ waited.current ||= waiting;
+ useEffect(
+ () => () => {
+ if (waited.current) void queryClient.invalidateQueries();
+ },
+ [],
+ );
+
+ /*
+ * A FORM, OR A CONNECTION THAT ALREADY HAS A ROW, IS SETTINGS' JOB. A typed key has nowhere to be
+ * typed but the app's page, and a Composio app whose row still stands — a refused key, an account
+ * the vendor lost — is refused by the connect route until it is disconnected there. Only this
+ * deployment's own OAuth flow takes a fresh consent over a withdrawn one.
+ */
+ const viaSettings =
+ access.connect === "key" ||
+ (access.state === "reconnect" && access.connect !== "oauth") ||
+ tab.needsKey;
+ if (viaSettings)
+ return (
+
+ }
+ aria-label={`${label} ${access.displayName}`}
+ size="sm"
+ variant="outline"
+ >
+ {label}
+
+ );
+
+ return (
+
+ );
+}
+
+/** A step only an administrator can take: ask for it, and withdraw the ask. */
+function RequestAction({
+ agentId,
+ access,
+}: {
+ agentId: string;
+ access: AppAccess;
+}) {
+ const request = useMutation(requestAppAccessMutationOptions(queryClient));
+ const cancel = useMutation(
+ cancelAppAccessRequestMutationOptions(queryClient),
+ );
+ const error = request.error ?? cancel.error;
+
+ /*
+ * FOCUS FOLLOWS THE PRESS. Request access and Withdraw replace each other, so the button somebody
+ * pressed leaves the page the moment the server's answer is drawn, and the focus with it — onto
+ * the body, where a keyboard user starts again from the top. The press is remembered, and when
+ * the other button arrives in its place it takes the focus. A refused press leaves the same
+ * button standing, which gets it back.
+ */
+ const control = useRef(null);
+ const pressed = useRef(false);
+ const requested = access.request !== null;
+ // biome-ignore lint/correctness/useExhaustiveDependencies: runs on the swap between the two buttons.
+ useEffect(() => {
+ if (!pressed.current) return;
+ pressed.current = false;
+ control.current?.focus();
+ }, [requested]);
+ const refocus = {
+ onError: () => {
+ pressed.current = false;
+ control.current?.focus();
+ },
+ };
+
+ /*
+ * Nothing to ask for where the server names no request: a state a caller overrode from a refusal
+ * the server does not know about (the team account lost at the vendor), which the server would
+ * answer 409 because as far as it knows nothing is wrong. The sentence still says whose move it is.
+ */
+ if (!access.request && !access.requestKind) return null;
+
+ return (
+
+ );
+}
diff --git a/app/src/components/plugins/connect-in-new-tab.ts b/app/src/components/plugins/connect-in-new-tab.ts
new file mode 100644
index 0000000000..002f5b3dfd
--- /dev/null
+++ b/app/src/components/plugins/connect-in-new-tab.ts
@@ -0,0 +1,146 @@
+import { useMutation } from "@tanstack/react-query";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { connectAccountMutationOptions } from "@/lib/plugins/mutations";
+import { pluginKeys } from "@/lib/plugins/queries";
+import { queryClient } from "@/query-client";
+
+/**
+ * Start this person's own connection to one app in a new tab, the way Settings starts it.
+ *
+ * THE SAME WRITE AS SETTINGS, A DIFFERENT TAB. `connectAccountMutationOptions` is the connect route
+ * the connected-accounts page presses, returning to that page when the vendor is done, so the
+ * confirm that writes the connection down is the one that page already runs. What differs is where
+ * the consent screen opens: Settings navigates its own tab, because the person went there to
+ * connect; a card in a conversation or a row on a Bot's page opens a new one, because the person is
+ * in the middle of something and a full navigation several redirects long would take them out of
+ * it. This tab stays where it was, and whoever drew the button watches the server for the
+ * connection to appear — the return trip proves nothing, being an ordinary redirect in a tab this
+ * one cannot read.
+ *
+ * Lifted out of `ConnectAppCard`, which had it inline, so the Bot page and the app-access card start
+ * a connection with the same code rather than a second copy of the popup rules below.
+ */
+export function useConnectInNewTab() {
+ const [waiting, setWaiting] = useState(false);
+ /** This app takes a key somebody types, so there is no page to send them to. */
+ const [needsKey, setNeedsKey] = useState(false);
+ /** The authorization url, when the browser refused to open the tab holding it. */
+ const [blockedUrl, setBlockedUrl] = useState(null);
+ const connect = useMutation(connectAccountMutationOptions());
+ // Stable, so a caller can list it in an effect's dependencies without re-running it every render.
+ const stopWaiting = useCallback(() => setWaiting(false), []);
+ /** The tab the consent is open in, while there is one to watch. */
+ const handle = useRef(null);
+
+ /*
+ * A CLOSED TAB ENDS THE WAIT ONCE THIS TAB HAS THE FOCUS BACK. Somebody who closes the sign-in
+ * tab without finishing has given up, and a button still saying "Waiting for Gmail…" a minute
+ * later is waiting for nothing — so the handle is checked every second, and once it reads closed
+ * the wait ends and the plugin screens are asked once more, in case the consent did finish before
+ * the tab went.
+ *
+ * CLOSED MAY ALSO MEAN SEVERED, WHICH IS WHY THE FOCUS DECIDES. A vendor's sign-in page that sends
+ * a strict Cross-Origin-Opener-Policy — Google's does — moves the tab into a browsing context of
+ * its own, and the handle this tab holds then reads closed while the person is still on the
+ * consent screen. The handle cannot tell the two apart; the focus can. Closing a tab hands the
+ * focus straight back here, so the wait ends at once; a severed tab keeps it, so the button goes
+ * on waiting until the person comes back to this one, which is when there is an answer to show.
+ */
+ const onReturn = useRef<(() => void) | null>(null);
+ useEffect(() => {
+ if (!waiting) return;
+ const finish = () => {
+ onReturn.current = null;
+ setWaiting(false);
+ void queryClient.invalidateQueries({ queryKey: pluginKeys.all });
+ };
+ const timer = setInterval(() => {
+ if (!handle.current?.closed || onReturn.current) return;
+ handle.current = null;
+ if (document.hasFocus()) {
+ finish();
+ return;
+ }
+ onReturn.current = finish;
+ window.addEventListener("focus", finish, { once: true });
+ }, 1_000);
+ return () => clearInterval(timer);
+ }, [waiting]);
+ // Goes with whoever drew the button, and with a wait that ended some other way first.
+ useEffect(() => {
+ if (waiting || !onReturn.current) return;
+ window.removeEventListener("focus", onReturn.current);
+ onReturn.current = null;
+ }, [waiting]);
+ useEffect(
+ () => () => {
+ if (onReturn.current)
+ window.removeEventListener("focus", onReturn.current);
+ },
+ [],
+ );
+
+ /**
+ * The tab, opened on the click itself rather than on the answer to it.
+ *
+ * A BROWSER ONLY LETS A PAGE OPEN A TAB WHILE A CLICK IS STILL RECENT. Chrome's transient
+ * activation lasts about five seconds, and the request that mints the authorization url has to
+ * finish first — so on a slow deployment `window.open` was called with the activation already
+ * expired, the tab was blocked, and the card cheerfully said to finish in a tab that did not
+ * exist. The blank tab is claimed first and navigated once the url arrives.
+ *
+ * `noopener` CANNOT BE PASSED HERE: it makes `window.open` answer null, and null is a handle
+ * nothing can navigate later. The opener is severed on the handle instead, which is the same
+ * guarantee by a different route.
+ */
+ const claimTab = (): Window | null => {
+ const tab = window.open("", "_blank");
+ if (tab) tab.opener = null;
+ return tab;
+ };
+
+ const open = (serverId: string) => {
+ const tab = claimTab();
+ handle.current = tab;
+ connect.mutate(serverId, {
+ onSuccess: (authorizationUrl) => {
+ /*
+ * No url means this app is connected by typing a key rather than by visiting a page — the
+ * route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page
+ * that has the form, beats a button that silently does nothing.
+ */
+ if (!authorizationUrl) {
+ tab?.close();
+ handle.current = null;
+ setNeedsKey(true);
+ return;
+ }
+ setWaiting(true);
+ if (tab) tab.location.replace(authorizationUrl);
+ // The tab was blocked. The url is still good, so offer it as something to click.
+ else setBlockedUrl(authorizationUrl);
+ },
+ onError: () => {
+ tab?.close();
+ handle.current = null;
+ },
+ });
+ };
+
+ return {
+ open,
+ /** A consent is open in the other tab; whoever drew the button should be watching. */
+ waiting,
+ /** Called once the server lists the connection, so the button stops saying it is waiting. */
+ stopWaiting,
+ needsKey,
+ blockedUrl,
+ pending: connect.isPending,
+ /** The connect route's own sentence, when it refused. */
+ error: connect.isError
+ ? connect.error instanceof Error
+ ? connect.error.message
+ : "That account could not be connected."
+ : null,
+ };
+}
diff --git a/app/src/lib/app-access/mutations.ts b/app/src/lib/app-access/mutations.ts
new file mode 100644
index 0000000000..31131a5df2
--- /dev/null
+++ b/app/src/lib/app-access/mutations.ts
@@ -0,0 +1,101 @@
+import { mutationOptions, type QueryClient } from "@tanstack/react-query";
+import { client } from "@/lib/client";
+import { pluginKeys } from "@/lib/plugins/queries";
+import type { AppAccessRequest } from "./queries";
+
+const FALLBACK = "That request could not be sent.";
+
+/*
+ * EVERY PLUGIN SCREEN, AND ON `onSettled`, for the reason `invalidatePlugins` in
+ * `lib/plugins/mutations.ts` sets out: approving does several durable things in a row — switch the
+ * app on, grant its tools, record a Shared approval — and a refusal partway leaves the earlier ones
+ * standing. The app-access keys sit under `pluginKeys.all`, so one invalidation covers the Bot page,
+ * the card, the inbox and the plugin screens the approval just changed.
+ */
+const refresh = (queryClient: QueryClient) =>
+ queryClient.invalidateQueries({ queryKey: pluginKeys.all });
+
+/**
+ * Ask an administrator for access to one app on one Bot. The server decides what is asked for from
+ * the app's state; a second press answers with the request already pending (`created: false`).
+ */
+export function requestAppAccessMutationOptions(queryClient: QueryClient) {
+ return mutationOptions({
+ mutationFn: async (variables: {
+ agentId: string;
+ app: string;
+ }): Promise<{ request: AppAccessRequest; created: boolean }> => {
+ const response = await client("/api/plugins/access-requests", {
+ method: "POST",
+ body: variables,
+ fallback: FALLBACK,
+ });
+ return response.json();
+ },
+ onSettled: () => refresh(queryClient),
+ });
+}
+
+/** Withdraw one's own pending request. */
+export function cancelAppAccessRequestMutationOptions(
+ queryClient: QueryClient,
+) {
+ return mutationOptions({
+ mutationFn: (id: string): Promise =>
+ client(
+ `/api/plugins/access-requests/${encodeURIComponent(id)}`,
+ "request",
+ {
+ method: "DELETE",
+ fallback: "That request could not be withdrawn.",
+ },
+ ),
+ onSettled: () => refresh(queryClient),
+ });
+}
+
+/**
+ * Approve a request by doing what it asks. `refs` names the tools to grant; absent, the Bot is
+ * granted the app's read-only tools only, and an app with none is answered 409 with a sentence
+ * pointing at the Bot's page, where the others are chosen. Answers with what changed.
+ */
+export function approveAppAccessRequestMutationOptions(
+ queryClient: QueryClient,
+) {
+ return mutationOptions({
+ mutationFn: async (variables: {
+ id: string;
+ refs?: string[];
+ }): Promise<{
+ request: AppAccessRequest;
+ enabled: boolean;
+ granted: string[];
+ }> => {
+ const response = await client(
+ `/api/plugins/access-requests/${encodeURIComponent(variables.id)}/approve`,
+ {
+ method: "POST",
+ ...(variables.refs ? { body: { refs: variables.refs } } : {}),
+ fallback: "That request could not be approved.",
+ },
+ );
+ return response.json();
+ },
+ onSettled: () => refresh(queryClient),
+ });
+}
+
+/** Dismiss a request without changing anything else. */
+export function dismissAppAccessRequestMutationOptions(
+ queryClient: QueryClient,
+) {
+ return mutationOptions({
+ mutationFn: (id: string): Promise =>
+ client(
+ `/api/plugins/access-requests/${encodeURIComponent(id)}/dismiss`,
+ "request",
+ { method: "POST", fallback: "That request could not be dismissed." },
+ ),
+ onSettled: () => refresh(queryClient),
+ });
+}
diff --git a/app/src/lib/app-access/queries.ts b/app/src/lib/app-access/queries.ts
new file mode 100644
index 0000000000..2c4313f2b5
--- /dev/null
+++ b/app/src/lib/app-access/queries.ts
@@ -0,0 +1,172 @@
+import { queryOptions } from "@tanstack/react-query";
+import { client } from "@/lib/client";
+
+/**
+ * What each work app is for the person looking, on one Bot, and the requests they file when only an
+ * administrator can change it.
+ *
+ * THE STATE IS THE SERVER'S. The browser draws `state` and `requestKind` as they arrive and never
+ * derives either from the other fields: whether an app is switched off, held by the Bot, connected
+ * by this person or waiting on a team account is read on the server from the same tables every
+ * call gate reads.
+ */
+
+/**
+ * The seven things a person can be told about one app on one Bot.
+ *
+ * `ready` — usable now. `not_connected` / `reconnect` — waiting on an account: this person's own,
+ * which they connect or reconnect themselves, or on somebody else's Team Bot the owner's, which only
+ * the owner can (see `accountHolder`). `shared_not_connected` — a Shared app whose team account an
+ * administrator has not connected. `shared_not_approved` — a Shared app whose team account IS
+ * connected, but an administrator has not approved this Bot's audience for it, so every call is
+ * refused; the refused call files the administrator's request itself, so there is none to press.
+ * `disabled` — switched off at the deployment, for the reason `unavailable` gives. `not_granted` —
+ * enabled, and this Bot holds none of it.
+ */
+export type AppAccessState =
+ | "ready"
+ | "not_connected"
+ | "reconnect"
+ | "shared_not_connected"
+ | "shared_not_approved"
+ | "disabled"
+ | "not_granted";
+
+/**
+ * Why an app is switched off. `absent` — nobody switched it on here. `composio_unconfigured` — a
+ * brokered app (Composio or Arcade) on a deployment with no key for its broker. The other three — a scheme the server cannot
+ * read, an OAuth client nobody registered, a token nobody pasted — are a set-up left unfinished.
+ */
+export type AppUnavailable =
+ | "absent"
+ | "composio_unconfigured"
+ | "scheme_unreadable"
+ | "oauth_client_missing"
+ | "token_missing";
+
+/** What a press of Request access asks an administrator for. */
+export type AppAccessRequestKind = "enable" | "grant" | "connect_shared";
+
+export type AppAccess = {
+ /** The server id the app has, or the one enabling it would mint. What a request names. */
+ key: string;
+ /** The app's own name — "Gmail", never a humanised server id. */
+ displayName: string;
+ logo: string | null;
+ /** A brokered app names its broker; a catalogue or custom app is reached directly. */
+ provider: "composio" | "arcade" | "catalogue" | "custom";
+ /** Whose account calls land in: each person's own, one team account, or nobody's. */
+ account: "personal" | "shared" | "deployment";
+ /** How the account is connected: this deployment's OAuth flow, a broker's consent, or a key form. */
+ connect: "oauth" | "consent" | "key" | null;
+ authScheme: string | null;
+ accountMode: "personal" | "shared" | null;
+ state: AppAccessState;
+ /** Why the app is switched off, when state is "disabled"; null otherwise. */
+ unavailable: AppUnavailable | null;
+ /**
+ * Whose account a personal-account app's calls use for this person on this Bot: "you", or "owner"
+ * when this is somebody else's published Team Bot and its owner has connected the app (calls then
+ * run on the owner's account). null when the app is not a personal-account app.
+ */
+ accountHolder: "you" | "owner" | null;
+ /**
+ * True when calls will use this person's OWN account on somebody else's Team Bot and they have not
+ * given standing consent: the first use raises an "Allow once / Always allow" card. State is "ready".
+ */
+ asksFirst: boolean;
+ /** Whether this Bot holds any of the app's tools. */
+ granted: boolean;
+ /**
+ * When the account this app runs in for this person was connected, or null. Read by the chat card
+ * to tell an account lost at the vendor from one connected again since. See `AppAccessCard`.
+ */
+ connectedAt: string | null;
+ /** The request Request access files for this state; null where the next step is the person's. */
+ requestKind: AppAccessRequestKind | null;
+ /** This person's pending request of exactly that kind — drawn as "Requested". */
+ request: { id: string; kind: AppAccessRequestKind; createdAt: string } | null;
+};
+
+/** A request as the person who filed it and the administrator answering it both read it. */
+export type AppAccessRequest = {
+ id: string;
+ appKey: string;
+ appTitle: string;
+ kind: AppAccessRequestKind;
+ status: "pending" | "approved" | "dismissed" | "cancelled";
+ agentId: string | null;
+ botName: string | null;
+ requestedBy: string;
+ requesterEmail: string | null;
+ requesterName: string | null;
+ createdAt: string;
+ resolvedAt: string | null;
+ resolvedBy: string | null;
+};
+
+/** The administrator's inbox: the requests asked for, and a count per app for the Plugins list. */
+export type AppAccessInbox = {
+ requests: AppAccessRequest[];
+ counts: { total: number; byApp: Record };
+};
+
+/*
+ * UNDER `["plugins"]`, so every plugin write that already refreshes the plugin screens — connecting
+ * an account, enabling an app, granting a tool — refreshes what these say too, without each of
+ * those writes having to know this view exists.
+ */
+export const appAccessKeys = {
+ all: ["plugins", "app-access"] as const,
+ bot: (agentId: string) => ["plugins", "app-access", "bot", agentId] as const,
+ app: (agentId: string, app: string) =>
+ ["plugins", "app-access", "bot", agentId, "app", app] as const,
+ inbox: (app?: string) =>
+ ["plugins", "app-access", "inbox", { app: app ?? null }] as const,
+};
+
+/** Every work app this Bot holds, with what each is for the person asking. */
+export function botAppsQueryOptions(agentId: string) {
+ return queryOptions({
+ queryKey: appAccessKeys.bot(agentId),
+ queryFn: (): Promise =>
+ client(`/api/plugins/for/${encodeURIComponent(agentId)}/apps`, "apps", {
+ fallback: "This Bot's apps could not be loaded.",
+ }),
+ });
+}
+
+/**
+ * One app, by key or by name, whether or not this Bot holds it — what a card in a conversation
+ * asks, so it never draws a stale state.
+ */
+export function botAppQueryOptions(agentId: string, app: string) {
+ return queryOptions({
+ queryKey: appAccessKeys.app(agentId, app),
+ queryFn: (): Promise =>
+ client(
+ `/api/plugins/for/${encodeURIComponent(agentId)}/apps/${encodeURIComponent(app)}`,
+ "app",
+ { fallback: `What ${app} is for this Bot could not be loaded.` },
+ ),
+ });
+}
+
+/**
+ * The pending requests an administrator answers: one app's, or all of them. The counts are always
+ * the whole deployment's. `enabled` is false for anybody who is not an administrator, who would be
+ * answered 403.
+ */
+export function appAccessInboxQueryOptions(enabled: boolean, app?: string) {
+ return queryOptions({
+ queryKey: appAccessKeys.inbox(app),
+ queryFn: async (): Promise => {
+ const query = app ? `?app=${encodeURIComponent(app)}` : "";
+ const response = await client(`/api/plugins/access-requests${query}`, {
+ fallback: "Access requests could not be loaded.",
+ });
+ return response.json();
+ },
+ enabled,
+ });
+}
diff --git a/app/src/lib/plugins/tool-name.ts b/app/src/lib/plugins/tool-name.ts
index b7306abbbb..3c23d69ad6 100644
--- a/app/src/lib/plugins/tool-name.ts
+++ b/app/src/lib/plugins/tool-name.ts
@@ -1,3 +1,4 @@
+import { SHOW_APP_ACCESS_TOOL } from "@openbot/shared/connect-prompt";
import {
RESEARCH_ROLE_LABELS,
researchRoleOfTool,
@@ -21,7 +22,9 @@ export type ToolName = {
detail?: string;
};
-export function readToolName(name: string): ToolName {
+export function readToolName(name: string, running = false): ToolName {
+ const own = ownToolLabel(name, running);
+ if (own) return { label: own };
const parts = name.split("__");
if (parts.length < 3 || parts[0] !== "mcp") return { label: name };
@@ -74,6 +77,28 @@ export function readToolName(name: string): ToolName {
return named ? { label } : { label, detail: broker ?? server };
}
+/**
+ * The tools every Bot holds that this deployment wrote itself, named in words somebody chose.
+ *
+ * These are not MCP names, so nothing above rewrites them, and the bare identifier is what a line
+ * would otherwise say: "show_app_access", shimmering, while the Bot works out what an app is for the
+ * person. Named twice, for the moment and for afterwards, because only these lines are drawn while
+ * running AND left on screen once done.
+ */
+const OWN_TOOLS: Record = {
+ [SHOW_APP_ACCESS_TOOL]: {
+ running: "Checking app access",
+ done: "Checked app access",
+ },
+};
+
+/** One of this deployment's own tools in words, or null for any other name. */
+export function ownToolLabel(name: string, running = false): string | null {
+ const own = OWN_TOOLS[name];
+ if (!own) return null;
+ return running ? own.running : own.done;
+}
+
/**
* The app behind a brokered server key, or null for a server this deployment dials itself.
*
diff --git a/app/src/routes/_authed/admin/plugins/$key.tsx b/app/src/routes/_authed/admin/plugins/$key.tsx
index 88b04b9ee6..56a4ebbda2 100644
--- a/app/src/routes/_authed/admin/plugins/$key.tsx
+++ b/app/src/routes/_authed/admin/plugins/$key.tsx
@@ -13,6 +13,7 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
+import { AccessRequestsSection } from "@/components/plugins/access-requests";
import { AccountModeDialog } from "@/components/plugins/account-mode-dialog";
import {
BrokeredAccountRow,
@@ -631,6 +632,13 @@ function RouteComponent() {
+ {/*
+ * People asking for this app: to switch it on, give it to a Bot, or connect its shared
+ * account. Only when somebody is; the section draws nothing otherwise. Requests name who
+ * asked — connections still name nobody.
+ */}
+
+
!open && setModeTarget(null)}
diff --git a/app/src/routes/_authed/admin/plugins/index.tsx b/app/src/routes/_authed/admin/plugins/index.tsx
index 4a6b122e72..20d1dc8000 100644
--- a/app/src/routes/_authed/admin/plugins/index.tsx
+++ b/app/src/routes/_authed/admin/plugins/index.tsx
@@ -14,6 +14,10 @@ import {
PageShell,
} from "@/components/layout/page-shell";
import { RowMark } from "@/components/layout/row-mark";
+import {
+ AccessRequestsSection,
+ usePendingRequestCounts,
+} from "@/components/plugins/access-requests";
import {
Item,
ItemActions,
@@ -102,6 +106,11 @@ function summaryFor(
function RouteComponent() {
const plugins = useQuery(pluginsPageQueryOptions());
const connections = useQuery(connectionsQueryOptions());
+ /*
+ * Requests waiting per app, so an administrator sees which app somebody is asking about from the
+ * list, without opening each one. Every request is also listed in full above the lists.
+ */
+ const requestCounts = usePendingRequestCounts();
// A NAME for the "More apps" row, which cannot be read off a row that may not exist yet.
const provider = pluginsProvider(plugins.data);
const moreApps = moreAppsRow(
@@ -140,6 +149,12 @@ function RouteComponent() {
) : (
<>
+ {/*
+ * First, because each one is somebody waiting. All of them, not only those about an app
+ * that is connected: an app nobody has switched on has no page of its own to list them on.
+ */}
+
+
+
{summaryFor(
server,
@@ -238,6 +256,9 @@ function RouteComponent() {
{entry.summary}
+
Not added
@@ -308,3 +329,16 @@ function RouteComponent() {
);
}
+
+/**
+ * "2 requests", beside a row's summary, in the foreground colour so it is the first thing on the
+ * row that reads as something to do. Nothing at zero: a count of nothing on every row is noise.
+ */
+function PendingRequests({ count }: { count: number }) {
+ if (count === 0) return null;
+ return (
+
+ {count} {count === 1 ? "request" : "requests"}
+
+ );
+}
diff --git a/app/src/routes/_authed/settings/connected-accounts/$key.tsx b/app/src/routes/_authed/settings/connected-accounts/$key.tsx
index ac21348848..81a113072b 100644
--- a/app/src/routes/_authed/settings/connected-accounts/$key.tsx
+++ b/app/src/routes/_authed/settings/connected-accounts/$key.tsx
@@ -340,6 +340,24 @@ function RouteComponent() {
);
}
+ /*
+ * A COMPOSIO APP ON A DEPLOYMENT WITH NO COMPOSIO KEY, BEFORE EITHER BRANCH BELOW DRAWS A ROW.
+ *
+ * The list on the way in no longer offers one, but a link from elsewhere still lands here, and the
+ * brokered row's own sentence for this case names an environment variable — an instruction for
+ * whoever runs the deployment, read by somebody who cannot act on it.
+ */
+ if (server?.provenance === "composio" && !plugins.data.composioConfigured) {
+ return (
+
+
+ {server.title} is not available on this OpenBot yet. An administrator
+ has to finish setting it up before anybody can connect it.
+
+
+ );
+ }
+
/*
* A SHARED APP, BEFORE THE BROKERED BRANCH BELOW GETS A LOOK AT IT.
*
diff --git a/app/src/routes/_authed/settings/connected-accounts/index.tsx b/app/src/routes/_authed/settings/connected-accounts/index.tsx
index e72fd5f8c9..ef34e1f7f2 100644
--- a/app/src/routes/_authed/settings/connected-accounts/index.tsx
+++ b/app/src/routes/_authed/settings/connected-accounts/index.tsx
@@ -216,6 +216,23 @@ export function brokeredAccountsListedOn(
);
}
+/**
+ * The servers this page may list at all: none reached through Composio when the deployment has no
+ * Composio key.
+ *
+ * An app an administrator enabled while a key was set keeps its row after the key is removed, and
+ * listing it offered a person an account they cannot connect, under a sentence about an environment
+ * variable only an administrator can set. Without the broker there is nothing for them to decide.
+ */
+export function serversListedFor(page: {
+ servers: PluginServer[];
+ composioConfigured: boolean;
+}): PluginServer[] {
+ return page.composioConfigured
+ ? page.servers
+ : page.servers.filter((server) => server.provenance !== "composio");
+}
+
/** Apps whose account is the organisation's, shown so a person knows a Bot does not act as them there. */
export function sharedAccountsListedOn(
servers: PluginServer[],
@@ -346,16 +363,17 @@ function RouteComponent() {
* unrecorded scheme included: a row whose column was never written is far likelier to be a key or
* consent app, and dropping it here would hide a connection somebody does have.
*/
- const brokered = brokeredAccountsListedOn(plugins.data?.servers ?? []);
+ const listed = plugins.data ? serversListedFor(plugins.data) : [];
+ const brokered = brokeredAccountsListedOn(listed);
/*
* Listed, never connected here. A Shared app's account is the organisation's, not this person's to
* make or to break, so this list draws no Connect action and links nowhere — it exists only so a
* person can see that a Bot calling this app acts as the team, not as them.
*/
- const shared = sharedAccountsListedOn(plugins.data?.servers ?? []);
+ const shared = sharedAccountsListedOn(listed);
const sectionsToShow = connectedAccountSections(
yours.length + brokered.length,
- plugins.data?.servers ?? [],
+ listed,
);
const accounts = [
...yours.map((entry) => {
diff --git a/app/tests/access-requests.test.tsx b/app/tests/access-requests.test.tsx
new file mode 100644
index 0000000000..e7949f560b
--- /dev/null
+++ b/app/tests/access-requests.test.tsx
@@ -0,0 +1,315 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ waitFor,
+ within,
+} from "@testing-library/react";
+import type { ReactNode } from "react";
+import { AccessRequestsSection } from "@/components/plugins/access-requests";
+import type { AppAccessRequest } from "@/lib/app-access/queries";
+import { Route as PluginsIndexRoute } from "@/routes/_authed/admin/plugins/index";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * The administrator's side of an access request: the Requests section on an app's admin page and
+ * at the top of the Plugins list, and the count on each row of that list.
+ *
+ * Approve and Dismiss go to the routes that do the work; a refusal from Approve — the step only an
+ * administrator can take first, with the request left pending — is drawn under the request it is
+ * about. Who asked is shown, because they asked; nobody's connection is.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(cleanup);
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = global.fetch;
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+const request = (
+ overrides: Partial = {},
+): AppAccessRequest => ({
+ id: "req_1",
+ appKey: "composio-gmail",
+ appTitle: "Gmail",
+ kind: "grant",
+ status: "pending",
+ agentId: "expenses",
+ botName: "Expenses",
+ requestedBy: "u_ada",
+ requesterEmail: "ada@example.test",
+ requesterName: "Ada",
+ createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
+ resolvedAt: null,
+ resolvedBy: null,
+ ...overrides,
+});
+
+function serving(options: {
+ requests: AppAccessRequest[];
+ approve?: { status: number; body: unknown };
+}) {
+ const sent: string[] = [];
+ global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = init?.method ?? "GET";
+ sent.push(`${method} ${url}`);
+ if (method === "POST" && url.endsWith("/approve"))
+ return Response.json(options.approve?.body ?? { request: {} }, {
+ status: options.approve?.status ?? 200,
+ });
+ if (method === "POST" && url.endsWith("/dismiss"))
+ return Response.json({ request: {} });
+ if (url.startsWith("/api/plugins/access-requests")) {
+ const app = new URL(url, "http://x").searchParams.get("app");
+ const byApp: Record = {};
+ for (const each of options.requests)
+ byApp[each.appKey] = (byApp[each.appKey] ?? 0) + 1;
+ return Response.json({
+ requests: app
+ ? options.requests.filter((each) => each.appKey === app)
+ : options.requests,
+ counts: { total: options.requests.length, byApp },
+ });
+ }
+ if (url === "/api/plugins/connections")
+ return Response.json({ connections: [], redirectUri: null });
+ if (url === "/api/plugins")
+ return Response.json({
+ catalogue: [],
+ servers: [
+ {
+ id: "composio-gmail",
+ title: "Gmail",
+ summary: "Mail.",
+ tools: [],
+ lastError: null,
+ },
+ {
+ id: "notion",
+ title: "Notion",
+ summary: "Docs.",
+ tools: [],
+ lastError: null,
+ },
+ ],
+ composioConfigured: true,
+ botsMayCallBack: true,
+ redirectUri: null,
+ skills: [],
+ });
+ return Response.json({});
+ }) as unknown as typeof fetch;
+ return sent;
+}
+
+function draw(Page: () => ReactNode) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const root = createRootRoute({ component: Outlet });
+ const page = createRoute({
+ getParentRoute: () => root,
+ path: "/",
+ component: Page,
+ });
+ const app = createRoute({
+ getParentRoute: () => root,
+ path: "/admin/plugins/$key",
+ component: () => null,
+ });
+ const composio = createRoute({
+ getParentRoute: () => root,
+ path: "/admin/plugins/composio",
+ component: () => null,
+ });
+ const bot = createRoute({
+ getParentRoute: () => root,
+ path: "/admin/plugins/$key/bots/$agentId",
+ component: () => null,
+ });
+ const router = createRouter({
+ routeTree: root.addChildren([page, app, composio, bot]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+test("lists who asked for what, for which Bot, and when — and nothing about anybody's connection", async () => {
+ serving({
+ requests: [
+ request(),
+ request({ id: "req_2", kind: "enable", appTitle: "Gmail" }),
+ request({
+ id: "req_3",
+ kind: "connect_shared",
+ requesterName: null,
+ }),
+ ],
+ });
+ const view = draw(() => );
+ expect(await view.findByText("Give Gmail to Expenses")).toBeTruthy();
+ expect(view.getByText("Turn on Gmail for Expenses")).toBeTruthy();
+ expect(view.getByText("Connect the shared Gmail account")).toBeTruthy();
+ expect(view.getAllByText(/Ada asked 5 minutes ago/)).toHaveLength(2);
+ // No name on the request falls back to the address they asked from.
+ expect(view.getByText(/ada@example\.test asked/)).toBeTruthy();
+ expect(view.queryByText(/connected/i)).toBeNull();
+});
+
+test("approve does what was asked through the approve route", async () => {
+ const sent = serving({ requests: [request()] });
+ const view = draw(() => );
+ fireEvent.click(await view.findByRole("button", { name: "Approve" }));
+ await waitFor(() =>
+ expect(sent).toContain("POST /api/plugins/access-requests/req_1/approve"),
+ );
+});
+
+test("an approval the server cannot complete says why, on the request, and leaves it there", async () => {
+ serving({
+ requests: [request({ kind: "connect_shared" })],
+ approve: {
+ status: 409,
+ body: {
+ error:
+ "The shared Gmail account is not connected yet. Connect it on Gmail's page under Plugins, then approve this request.",
+ },
+ },
+ });
+ const view = draw(() => );
+ fireEvent.click(await view.findByRole("button", { name: "Approve" }));
+ const row = within(
+ (await view.findByRole("alert")).closest("[data-request]") as HTMLElement,
+ );
+ expect(row.getByRole("alert").textContent).toMatch(
+ /^The shared Gmail account is not connected yet\./,
+ );
+ expect(row.getByRole("button", { name: "Approve" })).toBeTruthy();
+});
+
+test("a grant says approving gives the Bot read-only actions, and links to where the others are chosen", async () => {
+ serving({ requests: [request()] });
+ const view = draw(() => );
+ const element = (await view.findByText("Give Gmail to Expenses")).closest(
+ "[data-request]",
+ ) as HTMLElement;
+ const row = within(element);
+ expect(element.textContent).toContain(
+ "Approving gives Expenses only Gmail's read-only actions. Choose any others on the Bot's page under Gmail.",
+ );
+ const page = row.getByRole("link", {
+ name: "the Bot's page under Gmail",
+ });
+ expect(page.getAttribute("href")).toBe(
+ "/admin/plugins/composio-gmail/bots/expenses",
+ );
+});
+
+test("switching an app on for a Bot says the same, with no link to a page the app does not have yet", async () => {
+ serving({ requests: [request({ kind: "enable" })] });
+ const view = draw(() => );
+ const row = within(
+ (await view.findByText("Turn on Gmail for Expenses")).closest(
+ "[data-request]",
+ ) as HTMLElement,
+ );
+ expect(
+ row.getByText(
+ /Approving gives Expenses only Gmail's read-only actions\. Choose any others on the Bot's page under Gmail\./,
+ ),
+ ).toBeTruthy();
+ expect(row.queryByRole("link")).toBeNull();
+});
+
+test("connecting the shared account says nothing about actions", async () => {
+ serving({ requests: [request({ kind: "connect_shared" })] });
+ const view = draw(() => );
+ await view.findByText("Connect the shared Gmail account");
+ expect(view.queryByText(/read-only/)).toBeNull();
+});
+
+test("an app with no read-only actions is refused on the row, which stays", async () => {
+ serving({
+ requests: [request()],
+ approve: {
+ status: 409,
+ body: {
+ error:
+ "Gmail has no read-only actions to grant. Choose which actions Expenses may use on its page under Gmail.",
+ },
+ },
+ });
+ const view = draw(() => );
+ fireEvent.click(await view.findByRole("button", { name: "Approve" }));
+ expect((await view.findByRole("alert")).textContent).toMatch(
+ /^Gmail has no read-only actions to grant\./,
+ );
+ expect(view.getByText("Give Gmail to Expenses")).toBeTruthy();
+});
+
+test("dismiss goes to the dismiss route", async () => {
+ const sent = serving({ requests: [request()] });
+ const view = draw(() => );
+ fireEvent.click(await view.findByRole("button", { name: "Dismiss" }));
+ await waitFor(() =>
+ expect(sent).toContain("POST /api/plugins/access-requests/req_1/dismiss"),
+ );
+});
+
+test("nothing waiting draws no section at all", async () => {
+ const sent = serving({ requests: [] });
+ const view = draw(() => );
+ await waitFor(() =>
+ expect(sent).toContain(
+ "GET /api/plugins/access-requests?app=composio-gmail",
+ ),
+ );
+ await settleReactWork();
+ expect(view.queryByText("Requests")).toBeNull();
+});
+
+test("the Plugins list puts every request first and counts them on each app's row", async () => {
+ serving({
+ requests: [
+ request(),
+ request({ id: "req_2", kind: "enable" }),
+ // An app with no row of its own: its request is still listed, at the top.
+ request({
+ id: "req_3",
+ appKey: "composio-slack",
+ appTitle: "Slack",
+ kind: "enable",
+ }),
+ ],
+ });
+ const Page = PluginsIndexRoute.options.component as () => ReactNode;
+ const view = draw(Page);
+ expect(await view.findByText("Requests")).toBeTruthy();
+ expect(view.getByText("Turn on Slack for Expenses")).toBeTruthy();
+ const gmail = within(await view.findByTestId("plugin-composio-gmail"));
+ expect(gmail.getByText("2 requests")).toBeTruthy();
+ const notion = within(view.getByTestId("plugin-notion"));
+ expect(notion.queryByText(/request/)).toBeNull();
+});
diff --git a/app/tests/app-access-action.test.tsx b/app/tests/app-access-action.test.tsx
new file mode 100644
index 0000000000..728aac0806
--- /dev/null
+++ b/app/tests/app-access-action.test.tsx
@@ -0,0 +1,448 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ waitFor,
+} from "@testing-library/react";
+import { useState } from "react";
+import {
+ AppAccessAction,
+ appAccessSentence,
+} from "@/components/plugins/app-access-action";
+import type { AppAccess } from "@/lib/app-access/queries";
+import { appAccess } from "./app-access-fixture";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * The one piece the Bot page and the chat card both draw an app's state with.
+ *
+ * Each of the seven states must say whose move it is and offer exactly that move — and nothing for a
+ * state that needs none, or a move that is somebody else's: an administrator's, or on somebody else's
+ * Team Bot its owner's. The two moves that write are asserted against the request they send: the
+ * browser names a Bot and an app and never a kind, which is the server's to decide.
+ */
+
+let originalOpen: typeof window.open;
+beforeAll(() => {
+ GlobalRegistrator.register();
+ originalOpen = window.open;
+});
+afterEach(cleanup);
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = global.fetch;
+afterEach(() => {
+ global.fetch = originalFetch;
+ window.open = originalOpen;
+});
+
+type Sent = { method: string; url: string; body: unknown };
+
+function mounted(
+ access: AppAccess,
+ answer?: { status: number; body: unknown },
+) {
+ const sent: Sent[] = [];
+ const opened: string[] = [];
+ // The tab the last Connect claimed, so a test can close it the way a person would.
+ const tabs: { closed: boolean }[] = [];
+ window.open = (() => {
+ const tab = {
+ opener: {} as unknown,
+ closed: false,
+ location: { replace: (next: string) => opened.push(next) },
+ close: () => {},
+ };
+ tabs.push(tab);
+ return tab as unknown as Window;
+ }) as typeof window.open;
+ global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ const method = init?.method ?? "GET";
+ sent.push({
+ method,
+ url,
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
+ });
+ if (answer && method !== "GET")
+ return Response.json(answer.body, { status: answer.status });
+ if (url.endsWith("/connect?returnTo=settings"))
+ return Response.json({ authorizationUrl: "https://gmail.test/consent" });
+ return Response.json({ request: { id: "req_1" }, created: true });
+ }) as unknown as typeof fetch;
+
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ // Stands in for the refetch after a write: the server's next answer, handed to the same row.
+ let swap: (next: AppAccess) => void = () => {};
+ function Row() {
+ const [current, setCurrent] = useState(access);
+ swap = setCurrent;
+ return (
+
+
{appAccessSentence(current)}
+
+
+ );
+ }
+ const root = createRootRoute({ component: Outlet });
+ const page = createRoute({
+ getParentRoute: () => root,
+ path: "/",
+ component: Row,
+ });
+ const settings = createRoute({
+ getParentRoute: () => root,
+ path: "/settings/connected-accounts/$key",
+ component: () => null,
+ });
+ const router = createRouter({
+ routeTree: root.addChildren([page, settings]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ const view = render(
+
+
+ ,
+ );
+ return {
+ view,
+ sent,
+ opened,
+ tabs,
+ swap: (next: AppAccess) => act(() => swap(next)),
+ };
+}
+
+const COPY: [string, Partial, RegExp][] = [
+ ["ready", {}, /^Ready to use\.$/],
+ [
+ "ready, on the owner's account",
+ { accountHolder: "owner" },
+ /^Ready to use\. This Bot uses its owner's Gmail account\.$/,
+ ],
+ [
+ "ready, asking first",
+ { asksFirst: true },
+ /^Ready to use with your own Gmail account\. You'll be asked to allow it the first time\.$/,
+ ],
+ [
+ "not_connected",
+ { state: "not_connected" },
+ /You haven't connected your Gmail account yet\./,
+ ],
+ [
+ "reconnect",
+ { state: "reconnect" },
+ /^Your Gmail connection needs reconnecting\.$/,
+ ],
+ [
+ "reconnect, on the owner's account",
+ { state: "reconnect", accountHolder: "owner" },
+ /^This Bot uses its owner's Gmail account, which needs reconnecting\. Only the Bot's owner can reconnect it\.$/,
+ ],
+ [
+ "shared_not_connected",
+ { state: "shared_not_connected" },
+ /Your organisation's Gmail account isn't connected yet\. Only an administrator can connect it\./,
+ ],
+ [
+ "shared_not_approved",
+ { state: "shared_not_approved" },
+ /^This Bot reaches more people than an administrator has approved for your organisation's Gmail account\. Only an administrator can approve it\.$/,
+ ],
+ [
+ "disabled, never switched on",
+ { state: "disabled", unavailable: "absent" },
+ /^Gmail isn't switched on for this OpenBot\. Only an administrator can switch it on\.$/,
+ ],
+ [
+ "disabled, with no reason given",
+ { state: "disabled", unavailable: null },
+ /^Gmail isn't switched on for this OpenBot\./,
+ ],
+ [
+ "disabled, with no Composio key",
+ { state: "disabled", unavailable: "composio_unconfigured" },
+ /^Gmail is not available on this OpenBot yet\. An administrator has to finish setting it up\.$/,
+ ],
+ ...(
+ ["scheme_unreadable", "oauth_client_missing", "token_missing"] as const
+ ).map((unavailable): [string, Partial, RegExp] => [
+ `disabled, ${unavailable}`,
+ { state: "disabled", unavailable },
+ /^Gmail isn't fully set up yet\. Only an administrator can finish setting it up\.$/,
+ ]),
+ [
+ "not_granted",
+ { state: "not_granted" },
+ /This Bot hasn't been given Gmail\. Only an administrator can give it\./,
+ ],
+];
+
+test.each(COPY)("says whose move it is: %s", (_, overrides, sentence) => {
+ expect(appAccessSentence(appAccess(overrides))).toMatch(sentence);
+});
+
+test("no sentence about a switched-off app says an administrator turned it off", () => {
+ for (const [, overrides] of COPY.filter(([, o]) => o.state === "disabled"))
+ expect(appAccessSentence(appAccess(overrides))).not.toMatch(/Turned off/);
+});
+
+test("ready offers nothing to press", async () => {
+ const { view } = mounted(appAccess());
+ await view.findByText("Ready to use.");
+ expect(view.queryByRole("button")).toBeNull();
+ expect(view.queryByRole("link")).toBeNull();
+});
+
+test("not connected offers Connect, which starts the person's own consent in a new tab", async () => {
+ const { view, sent, opened } = mounted(appAccess({ state: "not_connected" }));
+ fireEvent.click(await view.findByRole("button", { name: "Connect Gmail" }));
+ await waitFor(() => expect(opened).toEqual(["https://gmail.test/consent"]));
+ expect(sent.map((call) => `${call.method} ${call.url}`)).toContain(
+ "POST /api/plugins/servers/composio-gmail/connect?returnTo=settings",
+ );
+ await view.findByText("Finish in the tab that opened.");
+});
+
+test("an app connected with a typed key goes to its page in Settings, where the form is", async () => {
+ const { view } = mounted(
+ appAccess({ state: "not_connected", connect: "key" }),
+ );
+ // Drawn as a button (`Button` over a `Link`), and an anchor underneath with the address.
+ const link = (await view.findByText("Connect")).closest("a");
+ expect(link?.getAttribute("href")).toBe(
+ "/settings/connected-accounts/composio-gmail",
+ );
+});
+
+test("a Composio app to reconnect goes to Settings, because the connect route refuses a row that stands", async () => {
+ const { view } = mounted(appAccess({ state: "reconnect" }));
+ const link = (await view.findByText("Reconnect")).closest("a");
+ expect(link?.getAttribute("href")).toBe(
+ "/settings/connected-accounts/composio-gmail",
+ );
+});
+
+test("this deployment's own OAuth app reconnects in place, with a fresh consent", async () => {
+ const { view, opened } = mounted(
+ appAccess({
+ key: "notion",
+ displayName: "Notion",
+ provider: "catalogue",
+ connect: "oauth",
+ state: "reconnect",
+ }),
+ );
+ fireEvent.click(
+ await view.findByRole("button", { name: "Reconnect Notion" }),
+ );
+ await waitFor(() => expect(opened).toHaveLength(1));
+});
+
+/** Whether this tab has the focus, as `document.hasFocus` answers it, for the length of `run`. */
+async function withFocus(focused: boolean, run: () => Promise) {
+ const real = document.hasFocus;
+ document.hasFocus = () => focused;
+ try {
+ await run();
+ } finally {
+ document.hasFocus = real;
+ }
+}
+
+test("closing the sign-in tab ends the wait, rather than waiting for it forever", async () => {
+ await withFocus(true, async () => {
+ const { view, tabs } = mounted(appAccess({ state: "not_connected" }));
+ fireEvent.click(await view.findByRole("button", { name: "Connect Gmail" }));
+ await view.findByText("Waiting for Gmail…");
+ const [tab] = tabs;
+ if (!tab) throw new Error("no tab was claimed");
+ tab.closed = true;
+ await waitFor(
+ () =>
+ expect(
+ view.getByRole("button", { name: "Connect Gmail" }),
+ ).toBeTruthy(),
+ { timeout: 3_000 },
+ );
+ expect(view.queryByText("Waiting for Gmail…")).toBeNull();
+ expect(view.queryByText("Finish in the tab that opened.")).toBeNull();
+ });
+});
+
+test("a sign-in tab that only reads closed keeps the wait until this tab has the focus back", async () => {
+ await withFocus(false, async () => {
+ const { view, tabs } = mounted(appAccess({ state: "not_connected" }));
+ fireEvent.click(await view.findByRole("button", { name: "Connect Gmail" }));
+ await view.findByText("Waiting for Gmail…");
+ const [tab] = tabs;
+ if (!tab) throw new Error("no tab was claimed");
+ // A vendor's opener policy severs the handle while the person is still on its consent screen.
+ tab.closed = true;
+ await new Promise((resolve) => setTimeout(resolve, 1_500));
+ expect(view.getByText("Waiting for Gmail…")).toBeTruthy();
+ // They come back to this tab: there is an answer to show now.
+ window.dispatchEvent(new Event("focus"));
+ await waitFor(() =>
+ expect(view.queryByText("Waiting for Gmail…")).toBeNull(),
+ );
+ });
+});
+
+test("a Reconnect on the owner's account offers nothing: only the owner can reconnect it", async () => {
+ const { view } = mounted(
+ appAccess({ state: "reconnect", accountHolder: "owner" }),
+ );
+ await view.findByText(/Only the Bot's owner can reconnect it/);
+ expect(view.queryByRole("button")).toBeNull();
+ expect(view.queryByRole("link")).toBeNull();
+});
+
+test("a Shared app waiting on an administrator's approval offers nothing: the refused call already asked", async () => {
+ const { view } = mounted(
+ appAccess({
+ state: "shared_not_approved",
+ account: "shared",
+ accountMode: "shared",
+ }),
+ );
+ await view.findByText(/Only an administrator can approve it/);
+ expect(view.queryByRole("button")).toBeNull();
+});
+
+test.each([
+ [{ accountHolder: "owner" }, /owner's Gmail account/],
+ [{ asksFirst: true }, /asked to allow it the first time/],
+] as const)(
+ "ready on somebody else's Team Bot offers nothing to press",
+ async (overrides, sentence) => {
+ const { view } = mounted(appAccess(overrides));
+ await view.findByText(sentence);
+ expect(view.queryByRole("button")).toBeNull();
+ },
+);
+
+test.each([
+ ["disabled", "enable"],
+ ["not_granted", "grant"],
+ ["shared_not_connected", "connect_shared"],
+] as const)(
+ "%s offers Request access, naming the Bot and the app and never the kind",
+ async (state, kind) => {
+ const { view, sent } = mounted(appAccess({ state, requestKind: kind }));
+ fireEvent.click(
+ await view.findByRole("button", { name: "Request access to Gmail" }),
+ );
+ await waitFor(() =>
+ expect(sent.find((call) => call.method === "POST")).toEqual({
+ method: "POST",
+ url: "/api/plugins/access-requests",
+ body: { agentId: "expenses", app: "composio-gmail" },
+ }),
+ );
+ },
+);
+
+test("a pending request reads Requested, and Withdraw cancels it", async () => {
+ const { view, sent } = mounted(
+ appAccess({
+ state: "disabled",
+ requestKind: "enable",
+ request: {
+ id: "req_9",
+ kind: "enable",
+ createdAt: "2026-10-20T10:00:00.000Z",
+ },
+ }),
+ );
+ await view.findByText("Requested");
+ expect(
+ view.queryByRole("button", { name: "Request access to Gmail" }),
+ ).toBeNull();
+ fireEvent.click(
+ view.getByRole("button", { name: "Withdraw request for Gmail" }),
+ );
+ await waitFor(() =>
+ expect(
+ sent.some(
+ (call) =>
+ call.method === "DELETE" &&
+ call.url === "/api/plugins/access-requests/req_9",
+ ),
+ ).toBe(true),
+ );
+});
+
+test("the server's refusal of a request is drawn beside the button", async () => {
+ const { view } = mounted(
+ appAccess({ state: "not_granted", requestKind: "grant" }),
+ { status: 409, body: { error: "Gmail is ready to use." } },
+ );
+ fireEvent.click(
+ await view.findByRole("button", { name: "Request access to Gmail" }),
+ );
+ expect((await view.findByRole("alert")).textContent).toBe(
+ "Gmail is ready to use.",
+ );
+});
+
+test("a state with no request to file offers none, rather than a button the server would refuse", async () => {
+ const { view } = mounted(
+ appAccess({ state: "shared_not_connected", requestKind: null }),
+ );
+ await view.findByText(/organisation's Gmail account/);
+ expect(view.queryByRole("button")).toBeNull();
+});
+
+test("the focus follows the press: Request access hands it to Withdraw, and Withdraw back", async () => {
+ const asked = appAccess({ state: "not_granted", requestKind: "grant" });
+ const { view, sent, swap } = mounted(asked);
+ const ask = await view.findByRole("button", {
+ name: "Request access to Gmail",
+ });
+ ask.focus();
+ fireEvent.click(ask);
+ await waitFor(() =>
+ expect(sent.some((call) => call.method === "POST")).toBe(true),
+ );
+ const pending = {
+ ...asked,
+ request: {
+ id: "req_1",
+ kind: "grant" as const,
+ createdAt: "2026-10-20T10:00:00.000Z",
+ },
+ };
+ await swap(pending);
+ const withdraw = await view.findByRole("button", {
+ name: "Withdraw request for Gmail",
+ });
+ expect(document.activeElement).toBe(withdraw);
+
+ fireEvent.click(withdraw);
+ await waitFor(() =>
+ expect(sent.some((call) => call.method === "DELETE")).toBe(true),
+ );
+ await swap(asked);
+ expect(document.activeElement).toBe(
+ await view.findByRole("button", { name: "Request access to Gmail" }),
+ );
+});
diff --git a/app/tests/app-access-card.test.tsx b/app/tests/app-access-card.test.tsx
new file mode 100644
index 0000000000..d9746cc06d
--- /dev/null
+++ b/app/tests/app-access-card.test.tsx
@@ -0,0 +1,390 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { cleanup, render, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { AppAccessCard } from "@/components/channels/app-access-card";
+import { ServerToolLine } from "@/components/channels/chat-transcript";
+import type { AppAccess } from "@/lib/app-access/queries";
+import { ActiveBotProvider, useActiveBot } from "@/lib/copilot/active-bot";
+import { REFUSAL_MARKER } from "@/lib/plugins/tool-result";
+import {
+ type ConnectPrompt,
+ connectAppRefusal,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
+import { appAccess } from "./app-access-fixture";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * The card a conversation draws when a Bot could not use an app, for every way that happens: the
+ * person has not connected, a Shared app has no team account, the vendor lost the account, and the
+ * `show_app_access` tool a Bot calls for an app it holds nothing for (switched off, not granted).
+ *
+ * The card never draws what the refusal said: it asks the server what the app is for this person
+ * on the Bot that refused, and draws that. The one exception is an account the vendor lost, which
+ * the server cannot know about — drawn as Reconnect over a live `ready`, until a connection newer
+ * than the refusal says it has been put back.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(cleanup);
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = global.fetch;
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+/** Answers the live read with `live`, and records which Bot and app it was asked about. */
+function serving(live: AppAccess | null) {
+ const asked: string[] = [];
+ global.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ asked.push(url);
+ if (url.includes("/apps/"))
+ return live
+ ? Response.json({ app: live })
+ : Response.json({ error: "nope" }, { status: 500 });
+ if (url.includes("/api/plugins/connections"))
+ return Response.json({ connections: [], redirectUri: null });
+ return Response.json({});
+ }) as unknown as typeof fetch;
+ return asked;
+}
+
+function mount(children: ReactNode) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const root = createRootRoute({ component: Outlet });
+ const page = createRoute({
+ getParentRoute: () => root,
+ path: "/",
+ component: () => <>{children}>,
+ });
+ const settings = createRoute({
+ getParentRoute: () => root,
+ path: "/settings/connected-accounts/$key",
+ component: () => null,
+ });
+ const router = createRouter({
+ routeTree: root.addChildren([page, settings]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+const prompt = (overrides: Partial = {}): ConnectPrompt => ({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ message:
+ "You have not connected your Gmail account. Connect it and ask again.",
+ agentId: "expenses",
+ ...overrides,
+});
+
+const card = (view: ReturnType) =>
+ waitFor(() => {
+ const found = view.container.querySelector("[data-app-access]");
+ if (!found?.getAttribute("data-state")) throw new Error("not drawn yet");
+ return found as HTMLElement;
+ });
+
+test("asks about the app on the Bot that refused, not on another", async () => {
+ const asked = serving(appAccess({ state: "not_connected" }));
+ mount();
+ await waitFor(() =>
+ expect(asked).toContain("/api/plugins/for/expenses/apps/composio-gmail"),
+ );
+});
+
+test("not connected: names the app and offers Connect", async () => {
+ serving(appAccess({ state: "not_connected" }));
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain("Gmail");
+ expect(drawn.textContent).toContain(
+ "You haven't connected your Gmail account yet.",
+ );
+ expect(view.getByRole("button", { name: "Connect Gmail" })).toBeTruthy();
+});
+
+test("connected since the refusal: says so and offers nothing stale", async () => {
+ serving(appAccess({ state: "ready" }));
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain(
+ "Gmail is ready now. Ask again and the Bot can use it.",
+ );
+ expect(view.queryByRole("button")).toBeNull();
+});
+
+test("a Shared app with no team account: Request access, not a sentence", async () => {
+ serving(
+ appAccess({
+ state: "shared_not_connected",
+ account: "shared",
+ accountMode: "shared",
+ requestKind: "connect_shared",
+ }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain(
+ "Your organisation's Gmail account isn't connected yet.",
+ );
+ expect(
+ view.getByRole("button", { name: "Request access to Gmail" }),
+ ).toBeTruthy();
+});
+
+test("an account the vendor lost: Reconnect, though the server still reads ready", async () => {
+ serving(
+ appAccess({ state: "ready", connectedAt: "2026-10-01T00:00:00.000Z" }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("reconnect");
+ expect(drawn.textContent).toContain(
+ "Your Gmail connection needs reconnecting.",
+ );
+ expect(
+ (await view.findByText("Reconnect")).closest("a")?.getAttribute("href"),
+ ).toBe("/settings/connected-accounts/composio-gmail");
+});
+
+test("an account lost and then connected again: the newer connection wins", async () => {
+ serving(
+ appAccess({ state: "ready", connectedAt: "2026-10-20T12:05:00.000Z" }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("ready");
+ expect(view.queryByText("Reconnect")).toBeNull();
+});
+
+test("a team account the vendor lost: says an administrator has to reconnect it, with nothing to press", async () => {
+ serving(appAccess({ state: "ready", account: "shared" }));
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain(
+ "Your organisation's Gmail account needs reconnecting. Only an administrator can reconnect it.",
+ );
+ expect(view.queryByRole("button")).toBeNull();
+});
+
+test.each([
+ ["disabled", "enable", /Gmail isn't switched on for this OpenBot/],
+ ["not_granted", "grant", /This Bot hasn't been given Gmail/],
+] as const)(
+ "the Bot asked about an app that is %s: Request access",
+ async (state, kind, sentence) => {
+ serving(appAccess({ state, requestKind: kind, granted: false }));
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.textContent).toMatch(sentence);
+ expect(
+ view.getByRole("button", { name: "Request access to Gmail" }),
+ ).toBeTruthy();
+ },
+);
+
+test("asked about an app that is ready, it says so without claiming it was just connected", async () => {
+ serving(appAccess());
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain("Ready to use.");
+});
+
+test("an account lost on somebody else's Team Bot that runs on its owner's: only the owner can reconnect it", async () => {
+ serving(
+ appAccess({
+ state: "ready",
+ accountHolder: "owner",
+ connectedAt: "2026-10-01T00:00:00.000Z",
+ }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("reconnect");
+ expect(drawn.textContent).toContain(
+ "This Bot uses its owner's Gmail account, which needs reconnecting. Only the Bot's owner can reconnect it.",
+ );
+ expect(view.queryByRole("button")).toBeNull();
+ expect(view.queryByRole("link")).toBeNull();
+});
+
+test("a Shared app whose audience is not approved: says so, with nothing to press", async () => {
+ serving(
+ appAccess({
+ state: "shared_not_approved",
+ account: "shared",
+ accountMode: "shared",
+ }),
+ );
+ const view = mount();
+ const drawn = await card(view);
+ expect(drawn.textContent).toContain("Only an administrator can approve it.");
+ expect(view.queryByRole("button")).toBeNull();
+});
+
+/** Declares a Bot the way a channel does, around whatever it is given. */
+function Declaring({
+ botId,
+ children,
+}: {
+ botId: string;
+ children: ReactNode;
+}) {
+ useActiveBot(botId);
+ return <>{children}>;
+}
+
+test("an older refusal that names no Bot asks about the Bot the surface declared", async () => {
+ const asked = serving(appAccess({ state: "not_connected" }));
+ const view = mount(
+
+
+
+
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("not_connected");
+ expect(asked).toContain("/api/plugins/for/travel/apps/composio-gmail");
+});
+
+test("with no Bot to ask about at all, it is the Connect card it always was", async () => {
+ const asked = serving(null);
+ const view = mount();
+ expect(await view.findByText("Connect Gmail")).toBeTruthy();
+ expect(asked.some((url) => url.includes("/apps/"))).toBe(false);
+});
+
+test("a live read that fails falls back to the Connect card for a connect refusal", async () => {
+ serving(null);
+ const view = mount();
+ expect(await view.findByText("Connect Gmail")).toBeTruthy();
+});
+
+test("a live read that fails says so in the person's words, never the model's", async () => {
+ serving(null);
+ const view = mount(
+ ,
+ );
+ const alert = await view.findByRole("alert");
+ expect(alert.textContent).toBe(
+ "Gmail's status couldn't be loaded. Try again in a moment.",
+ );
+ expect(view.container.textContent).not.toContain("Do not browse");
+});
+
+/*
+ * THROUGH THE TRANSCRIPT'S OWN LINE. The card is drawn from a server tool's answer — a refusal, or
+ * the `show_app_access` result — by `ServerToolLine`, and only those two are read for a marker.
+ * That it is drawn rather than folded into "Used N tools" is `tool-activity.test.tsx`'s.
+ */
+test("the transcript draws a refusal's card", async () => {
+ serving(appAccess({ state: "not_connected" }));
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("not_connected");
+});
+
+test("the transcript draws show_app_access's card from its ordinary answer", async () => {
+ serving(
+ appAccess({ state: "disabled", requestKind: "enable", granted: false }),
+ );
+ const view = mount(
+ ,
+ );
+ const drawn = await card(view);
+ expect(drawn.getAttribute("data-state")).toBe("disabled");
+});
+
+test("an ordinary tool's answer is never read for a card, whatever it starts with", async () => {
+ serving(appAccess({ state: "not_connected" }));
+ const view = mount(
+ ,
+ );
+ await settleReactWork();
+ expect(view.container.querySelector("[data-app-access]")).toBeNull();
+});
+
+test("show_app_access is named for the person while it runs, not by its identifier", () => {
+ const view = mount();
+ return waitFor(() => {
+ expect(view.getByText("Checking app access")).toBeTruthy();
+ expect(view.container.textContent).not.toContain(SHOW_APP_ACCESS_TOOL);
+ });
+});
+
+test("show_app_access about an app nobody can name draws the line, never its words to the model", async () => {
+ const view = mount(
+ ,
+ );
+ expect(await view.findByText("Checked app access")).toBeTruthy();
+ expect(view.container.textContent).not.toContain("Say so plainly");
+ // Nothing behind a disclosure either: the line is not expandable.
+ expect(view.container.querySelector("details")).toBeNull();
+});
diff --git a/app/tests/app-access-fixture.ts b/app/tests/app-access-fixture.ts
new file mode 100644
index 0000000000..2a6a99e7e8
--- /dev/null
+++ b/app/tests/app-access-fixture.ts
@@ -0,0 +1,27 @@
+import type { AppAccess } from "@/lib/app-access/queries";
+
+/**
+ * One app as `GET /api/plugins/for/:agentId/apps` answers it: Gmail, through Composio, under each
+ * person's own account, ready. Each test overrides only the fields its case is about.
+ */
+export function appAccess(overrides: Partial = {}): AppAccess {
+ return {
+ key: "composio-gmail",
+ displayName: "Gmail",
+ logo: null,
+ provider: "composio",
+ account: "personal",
+ connect: "consent",
+ authScheme: "OAUTH2",
+ accountMode: "personal",
+ state: "ready",
+ unavailable: null,
+ accountHolder: "you",
+ asksFirst: false,
+ granted: true,
+ connectedAt: null,
+ requestKind: null,
+ request: null,
+ ...overrides,
+ };
+}
diff --git a/app/tests/app-access-mutations.test.ts b/app/tests/app-access-mutations.test.ts
new file mode 100644
index 0000000000..91f4a06ffd
--- /dev/null
+++ b/app/tests/app-access-mutations.test.ts
@@ -0,0 +1,123 @@
+import { afterEach, expect, test } from "bun:test";
+import { MutationObserver, QueryClient } from "@tanstack/react-query";
+import {
+ approveAppAccessRequestMutationOptions,
+ cancelAppAccessRequestMutationOptions,
+ dismissAppAccessRequestMutationOptions,
+ requestAppAccessMutationOptions,
+} from "../src/lib/app-access/mutations";
+import { appAccessKeys } from "../src/lib/app-access/queries";
+import { pluginKeys } from "../src/lib/plugins/queries";
+
+/**
+ * The access-request writes, as the browser makes them: what each sends, and that each refreshes
+ * every plugin screen on the way out — refused or not, for the reason `invalidatePlugins` gives.
+ */
+
+const realFetch = globalThis.fetch;
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+function answering(status: number, body: unknown) {
+ const sent: { url: string; method: string; body?: unknown }[] = [];
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
+ // A request with no body is recorded with no `body` key, so its absence can be asserted.
+ sent.push({
+ url,
+ method: init?.method ?? "GET",
+ ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}),
+ });
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+ }) as unknown as typeof fetch;
+ return sent;
+}
+
+async function run(
+ build: (queryClient: QueryClient) => unknown,
+ variables: unknown,
+) {
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false } },
+ });
+ const asked: unknown[] = [];
+ const invalidate = queryClient.invalidateQueries.bind(queryClient);
+ queryClient.invalidateQueries = ((filters: never) => {
+ asked.push(filters);
+ return invalidate(filters);
+ }) as typeof queryClient.invalidateQueries;
+ const observer = new MutationObserver(
+ queryClient,
+ build(queryClient) as never,
+ );
+ const result = await observer
+ .mutate(variables as never)
+ .catch((error: Error) => error);
+ return { asked, result };
+}
+
+test("every app-access key sits under the plugin root, so plugin writes refresh it", () => {
+ for (const key of [
+ appAccessKeys.all,
+ appAccessKeys.bot("b"),
+ appAccessKeys.app("b", "Gmail"),
+ appAccessKeys.inbox("composio-gmail"),
+ ])
+ expect(key.slice(0, 1)).toEqual([...pluginKeys.all]);
+});
+
+test("asking names the Bot and the app and nothing else", async () => {
+ const sent = answering(201, { request: { id: "r1" }, created: true });
+ const { asked, result } = await run(requestAppAccessMutationOptions, {
+ agentId: "bot-1",
+ app: "Gmail",
+ });
+ expect(sent).toEqual([
+ {
+ url: "/api/plugins/access-requests",
+ method: "POST",
+ body: { agentId: "bot-1", app: "Gmail" },
+ },
+ ]);
+ expect(result).toEqual({ request: { id: "r1" }, created: true });
+ expect(asked).toEqual([{ queryKey: pluginKeys.all }]);
+});
+
+test("approving sends refs only when given", async () => {
+ const sent = answering(200, { request: {}, enabled: false, granted: [] });
+ await run(approveAppAccessRequestMutationOptions, { id: "r1" });
+ await run(approveAppAccessRequestMutationOptions, {
+ id: "r2",
+ refs: ["composio-gmail/SEND"],
+ });
+ expect(sent).toEqual([
+ { url: "/api/plugins/access-requests/r1/approve", method: "POST" },
+ {
+ url: "/api/plugins/access-requests/r2/approve",
+ method: "POST",
+ body: { refs: ["composio-gmail/SEND"] },
+ },
+ ]);
+ // `toEqual` passes over a key it is not given, so the first approval's missing body is its own
+ // assertion: no refs means no body at all, not an empty one.
+ expect(sent[0]).not.toHaveProperty("body");
+});
+
+test("a refused write still refreshes every plugin screen", async () => {
+ for (const build of [
+ requestAppAccessMutationOptions,
+ cancelAppAccessRequestMutationOptions,
+ approveAppAccessRequestMutationOptions,
+ dismissAppAccessRequestMutationOptions,
+ ]) {
+ answering(409, { error: "That request has already been answered." });
+ const { asked, result } = await run(build, "r1");
+ expect((result as Error).message).toBe(
+ "That request has already been answered.",
+ );
+ expect(asked).toEqual([{ queryKey: pluginKeys.all }]);
+ }
+});
diff --git a/app/tests/bot-access-apps.test.tsx b/app/tests/bot-access-apps.test.tsx
new file mode 100644
index 0000000000..c5de93cedd
--- /dev/null
+++ b/app/tests/bot-access-apps.test.tsx
@@ -0,0 +1,291 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { cleanup, render, within } from "@testing-library/react";
+import { AccessSections } from "@/components/bot-profile/access";
+import type { AgentProfile } from "@/lib/agents/queries";
+import type { AppAccess } from "@/lib/app-access/queries";
+import { appAccess } from "./app-access-fixture";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * The Granted section of a Bot's page, which used to list what a Bot holds and say nothing about
+ * whether the person reading could use any of it.
+ *
+ * Each app is named by its own name — never "Composio Gmail" — and says what it is for the person
+ * looking, with the one move they have. An administrator keeps the way to the Bot's grants for each
+ * app; nobody gets anybody else's connection state, because the read answers for the asker alone.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(cleanup);
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = global.fetch;
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+const BOT: AgentProfile = {
+ id: "expenses",
+ name: "Expenses",
+ title: "Finance",
+ roleDescription: "Receipts.",
+ avatarSeed: "e",
+ visibility: "public",
+ endpoint: null,
+ builtIn: true,
+ hasAuth: false,
+ hasCallbackToken: false,
+ hidden: false,
+ pinned: false,
+ systemOwned: false,
+ canManage: false,
+ mine: false,
+};
+
+const tool = (ref: string, toolName: string) => ({
+ ref,
+ toolName,
+ description: "",
+ inputSchema: {},
+});
+
+function serving(options: {
+ role?: "admin" | "user";
+ apps?: AppAccess[] | "fail";
+ tools?: ReturnType[];
+}) {
+ const { role = "user", apps = [], tools = [] } = options;
+ global.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.endsWith("/api/plugins/for/expenses/apps"))
+ return apps === "fail"
+ ? Response.json({ error: "nope" }, { status: 500 })
+ : Response.json({ apps });
+ if (url.endsWith("/api/plugins/for/expenses"))
+ return Response.json({ tools, skills: [] });
+ if (url.includes("/handoff"))
+ return Response.json({
+ handoff: {
+ enabled: false,
+ canGrant: false,
+ reachable: [],
+ grantable: false,
+ },
+ });
+ if (url.includes("/api/agents")) return Response.json({ agents: [] });
+ return Response.json({
+ user: { id: "me", role, email: "me@example.test", name: "Me" },
+ });
+ }) as unknown as typeof fetch;
+}
+
+function draw() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const root = createRootRoute({ component: Outlet });
+ const page = createRoute({
+ getParentRoute: () => root,
+ path: "/",
+ component: () => ,
+ });
+ const grants = createRoute({
+ getParentRoute: () => root,
+ path: "/admin/plugins/$key/bots/$agentId",
+ component: () => null,
+ });
+ const settings = createRoute({
+ getParentRoute: () => root,
+ path: "/settings/connected-accounts/$key",
+ component: () => null,
+ });
+ const router = createRouter({
+ routeTree: root.addChildren([page, grants, settings]),
+ history: createMemoryHistory({ initialEntries: ["/"] }),
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+const row = async (view: ReturnType, key: string) => {
+ await view.findByText("Granted");
+ const found = await view.findByText((_, element) =>
+ Boolean(element?.getAttribute("data-app") === key),
+ );
+ return within(found);
+};
+
+test("each app is named by its own name and says it is ready, with its tools", async () => {
+ serving({
+ apps: [appAccess()],
+ tools: [
+ tool(
+ "composio-gmail/GMAIL_SEND_EMAIL",
+ "mcp__composio-gmail__GMAIL_SEND_EMAIL",
+ ),
+ tool(
+ "composio-gmail/GMAIL_FETCH_EMAILS",
+ "mcp__composio-gmail__GMAIL_FETCH_EMAILS",
+ ),
+ ],
+ });
+ const view = draw();
+ const gmail = await row(view, "composio-gmail");
+ expect(gmail.getByText("Gmail")).toBeTruthy();
+ expect(gmail.getByText(/^Ready to use\./)).toBeTruthy();
+ expect(gmail.getByText("2 tools")).toBeTruthy();
+ expect(view.queryByText(/Composio Gmail/)).toBeNull();
+ expect(gmail.queryByRole("button")).toBeNull();
+});
+
+test("an app this person has not connected says so and offers Connect", async () => {
+ serving({ apps: [appAccess({ state: "not_connected" })] });
+ const gmail = await row(draw(), "composio-gmail");
+ expect(
+ gmail.getByText("You haven't connected your Gmail account yet."),
+ ).toBeTruthy();
+ expect(gmail.getByRole("button", { name: "Connect Gmail" })).toBeTruthy();
+});
+
+test("an app only an administrator can fix offers Request access, and a pending one reads Requested", async () => {
+ serving({
+ apps: [
+ appAccess({
+ key: "composio-slack",
+ displayName: "Slack",
+ state: "shared_not_connected",
+ account: "shared",
+ accountMode: "shared",
+ requestKind: "connect_shared",
+ }),
+ appAccess({
+ key: "notion",
+ displayName: "Notion",
+ provider: "catalogue",
+ state: "disabled",
+ requestKind: "enable",
+ request: {
+ id: "req_1",
+ kind: "enable",
+ createdAt: "2026-10-20T10:00:00.000Z",
+ },
+ }),
+ ],
+ });
+ const view = draw();
+ const slack = await row(view, "composio-slack");
+ expect(
+ slack.getByText(
+ "Your organisation's Slack account isn't connected yet. Only an administrator can connect it.",
+ ),
+ ).toBeTruthy();
+ expect(
+ slack.getByRole("button", { name: "Request access to Slack" }),
+ ).toBeTruthy();
+ const notion = await row(view, "notion");
+ expect(
+ notion.getByText(
+ "Notion isn't switched on for this OpenBot. Only an administrator can switch it on.",
+ ),
+ ).toBeTruthy();
+ expect(notion.getByText("Requested")).toBeTruthy();
+ expect(
+ notion.getByRole("button", { name: "Withdraw request for Notion" }),
+ ).toBeTruthy();
+});
+
+test("an administrator keeps the way to this Bot's grants on each app, beside the action", async () => {
+ serving({ role: "admin", apps: [appAccess({ state: "not_connected" })] });
+ const gmail = await row(draw(), "composio-gmail");
+ const manage = gmail.getByRole("button", {
+ name: "Manage Gmail on this Bot",
+ });
+ expect(manage.getAttribute("href")).toBe(
+ "/admin/plugins/composio-gmail/bots/expenses",
+ );
+ expect(gmail.getByRole("button", { name: "Connect Gmail" })).toBeTruthy();
+});
+
+test("an app's sentence is never cut short, so who can fix it stays on screen at a phone's width", async () => {
+ serving({
+ apps: [
+ appAccess({
+ state: "disabled",
+ unavailable: "composio_unconfigured",
+ requestKind: "enable",
+ }),
+ ],
+ });
+ const gmail = await row(draw(), "composio-gmail");
+ const sentence = gmail.getByText(
+ "Gmail is not available on this OpenBot yet. An administrator has to finish setting it up.",
+ );
+ expect(sentence.className).toContain("line-clamp-none");
+ expect(sentence.className).not.toContain("line-clamp-2");
+});
+
+test("on somebody else's Team Bot, an app on its owner's account says so, and an owner's reconnection offers nothing", async () => {
+ serving({
+ apps: [
+ appAccess({ accountHolder: "owner" }),
+ appAccess({
+ key: "notion",
+ displayName: "Notion",
+ provider: "catalogue",
+ connect: "oauth",
+ state: "reconnect",
+ accountHolder: "owner",
+ }),
+ ],
+ });
+ const view = draw();
+ const gmail = await row(view, "composio-gmail");
+ expect(
+ gmail.getByText(
+ /^Ready to use\. This Bot uses its owner's Gmail account\./,
+ ),
+ ).toBeTruthy();
+ const notion = await row(view, "notion");
+ expect(
+ notion.getByText(
+ "This Bot uses its owner's Notion account, which needs reconnecting. Only the Bot's owner can reconnect it.",
+ ),
+ ).toBeTruthy();
+ expect(notion.queryByRole("button")).toBeNull();
+});
+
+test("a person who is not an administrator gets no way to the grants", async () => {
+ serving({ apps: [appAccess()] });
+ const gmail = await row(draw(), "composio-gmail");
+ expect(gmail.queryByRole("button", { name: /Manage/ })).toBeNull();
+});
+
+test("when the states cannot be read, the apps are still listed and the failure is said", async () => {
+ serving({
+ apps: "fail",
+ tools: [
+ tool("google-drive/search_files", "mcp__google-drive__search_files"),
+ ],
+ });
+ const view = draw();
+ expect((await view.findByRole("alert")).textContent).toContain(
+ "Whether each app is ready for you could not be loaded.",
+ );
+ expect(await view.findByText("Google Drive")).toBeTruthy();
+});
diff --git a/app/tests/bot-access-skills.test.tsx b/app/tests/bot-access-skills.test.tsx
index f8bab06846..fc7d4eee84 100644
--- a/app/tests/bot-access-skills.test.tsx
+++ b/app/tests/bot-access-skills.test.tsx
@@ -73,6 +73,9 @@ function serving() {
grants.push(`${init?.method ?? "GET"} ${url} ${init?.body ?? ""}`);
return Response.json({ ok: true });
}
+ if (url.includes("/api/plugins/for/") && url.endsWith("/apps")) {
+ return Response.json({ apps: [] });
+ }
if (url.includes("/api/plugins/for/")) {
return Response.json({ tools: [], skills: [] });
}
diff --git a/app/tests/bot-route-default-agent.fixture.tsx b/app/tests/bot-route-default-agent.fixture.tsx
index 2754e68b0a..dcab617581 100644
--- a/app/tests/bot-route-default-agent.fixture.tsx
+++ b/app/tests/bot-route-default-agent.fixture.tsx
@@ -19,9 +19,14 @@ mock.module("@copilotkit/react-core/v2", () => ({
),
}));
-mock.module("@/lib/copilot/active-bot", () => ({
- useActiveBot: () => undefined,
-}));
+/*
+ * `@/lib/copilot/active-bot` IS NOT MOCKED, though the route calls `useActiveBot`. Drawn without an
+ * `ActiveBotProvider`, as these tests draw it, the real hook finds no holder and does nothing — which
+ * is all the mock that used to be here did. And a module mock is not this file's to keep: bun cannot
+ * take one back off, so a replacement exporting only `useActiveBot` stood in for the whole module in
+ * every file walked into the same process, and `useDeclaredBotId` — the app-access card's fallback
+ * for a refusal that names no Bot — stopped existing there.
+ */
mock.module("@/lib/copilot/bot-thread", () => ({
useBotThread: (agentId: string) => {
diff --git a/app/tests/brokered-account-row.test.tsx b/app/tests/brokered-account-row.test.tsx
index 97b95f5583..335722fd3a 100644
--- a/app/tests/brokered-account-row.test.tsx
+++ b/app/tests/brokered-account-row.test.tsx
@@ -809,7 +809,11 @@ test("a disconnect that lands stops reading Connected and offers Connect again",
expect(server.deletes).toBe(1);
});
-test("a deployment with no Composio key names the setting and offers neither action", async () => {
+/*
+ * The setting is named on the admin page below, to somebody who can set it. Here the reader is the
+ * person who would connect the account, so the page says only that the app is not available yet.
+ */
+test("a deployment with no Composio key says the app is not available yet and offers neither action", async () => {
installDeployment({
composioConfigured: false,
// A row left over from when a key WAS set: the app keeps its row and its grants, which is the
@@ -820,8 +824,10 @@ test("a deployment with no Composio key names the setting and offers neither act
const view = renderAccountScreen(queryClient());
- expect(await view.findByText(NAMES_THE_SETTING)).toBeTruthy();
- expect(view.queryByText("Key missing")).toBeTruthy();
+ expect(
+ await view.findByText(/is not available on this OpenBot yet/),
+ ).toBeTruthy();
+ expect(view.queryByText(NAMES_THE_SETTING)).toBeNull();
expect(view.queryByText("Connected")).toBeNull();
expect(view.queryByRole("button", { name: "Disconnect" })).toBeNull();
expect(view.queryByRole("button", { name: "Connect" })).toBeNull();
diff --git a/app/tests/composio-unconfigured-account.test.tsx b/app/tests/composio-unconfigured-account.test.tsx
new file mode 100644
index 0000000000..f9252f7e8e
--- /dev/null
+++ b/app/tests/composio-unconfigured-account.test.tsx
@@ -0,0 +1,189 @@
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ expect,
+ test,
+} from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { cleanup, render } from "@testing-library/react";
+import {
+ type PluginConnections,
+ type PluginServer,
+ type PluginsPage,
+ pluginKeys,
+} from "@/lib/plugins/queries";
+import { Route as ConnectedAccountRoute } from "@/routes/_authed/settings/connected-accounts/$key";
+import { serversListedFor } from "@/routes/_authed/settings/connected-accounts/index";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * A Composio app on a deployment with no Composio key, as the person who would connect it sees it.
+ *
+ * An app an administrator enabled while a key was set keeps its row after the key goes. The
+ * Connected accounts list offered it, and its page drew "Set COMPOSIO_API_KEY on this deployment" —
+ * an instruction for whoever runs the deployment, shown to somebody who cannot act on it. The list
+ * now leaves it off, and the page says plainly that it is not available yet.
+ *
+ * The harness is `plugin-read-failure.test.tsx`'s: the exported `Route` singleton is captured and
+ * restored around its `.update()`.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(cleanup);
+// React may still have a passive effect queued when the last test ends; unregistering under it
+// throws "window is not defined" between tests, which fails the run though every test passed.
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+const originalFetch = global.fetch;
+
+beforeEach(() => {
+ // Every read this page makes is seeded below; anything else is a request the test did not expect.
+ global.fetch = Object.assign(
+ async () => new Response(null, { status: 404 }),
+ { preconnect: originalFetch.preconnect },
+ );
+});
+
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
+function server(overrides: Partial & { id: string }) {
+ return {
+ title: "App",
+ summary: "",
+ vendor: "Composio",
+ url: `composio://${overrides.id}`,
+ provenance: "composio",
+ authScheme: "OAUTH2",
+ tools: [],
+ ...overrides,
+ } as PluginServer;
+}
+
+const gmail = server({ id: "composio-gmail", title: "Gmail" });
+const drive = server({
+ id: "google-drive",
+ title: "Google Drive",
+ provenance: "catalogue",
+ vendor: "Google",
+} as Partial & { id: string });
+
+test("lists no Composio app when the deployment has no Composio key", () => {
+ expect(
+ serversListedFor({
+ servers: [gmail, drive],
+ composioConfigured: false,
+ }).map((row) => row.id),
+ ).toEqual(["google-drive"]);
+});
+
+test("lists Composio apps as before once the key is set", () => {
+ expect(
+ serversListedFor({
+ servers: [gmail, drive],
+ composioConfigured: true,
+ }).map((row) => row.id),
+ ).toEqual(["composio-gmail", "google-drive"]);
+});
+
+type Updatable = { update: (options: unknown) => unknown };
+
+const pristine = {
+ ...ConnectedAccountRoute,
+ options: { ...ConnectedAccountRoute.options },
+} as Record;
+
+afterEach(() => {
+ for (const key of Object.keys(ConnectedAccountRoute)) {
+ if (!(key in pristine)) {
+ delete (ConnectedAccountRoute as unknown as Record)[key];
+ }
+ }
+ Object.assign(ConnectedAccountRoute, pristine);
+});
+
+function renderAccount(composioConfigured: boolean) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const page: PluginsPage = {
+ catalogue: [],
+ servers: [gmail],
+ skills: [],
+ botsMayCallBack: true,
+ redirectUri: null,
+ composioConfigured,
+ // Composio's answer: its auth config fixes one state for a whole toolkit.
+ authorizesPerAction: false,
+ };
+ const connections: PluginConnections = { connections: [], redirectUri: null };
+ client.setQueryData(pluginKeys.page(), page);
+ client.setQueryData(pluginKeys.connections(), connections);
+
+ const rootRoute = createRootRoute({ component: Outlet });
+ const authedRoute = createRoute({
+ id: "/_authed",
+ getParentRoute: () => rootRoute,
+ component: Outlet,
+ });
+ const settingsRoute = createRoute({
+ path: "/settings",
+ getParentRoute: () => authedRoute,
+ component: Outlet,
+ });
+ const indexRoute = createRoute({
+ path: "/connected-accounts/",
+ getParentRoute: () => settingsRoute,
+ component: () => null,
+ });
+ const account = (ConnectedAccountRoute as unknown as Updatable).update({
+ id: "/connected-accounts/$key",
+ path: "/connected-accounts/$key",
+ getParentRoute: () => settingsRoute,
+ });
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ authedRoute.addChildren([
+ settingsRoute.addChildren([indexRoute, account as never]),
+ ]),
+ ]),
+ history: createMemoryHistory({
+ initialEntries: ["/settings/connected-accounts/composio-gmail"],
+ }),
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+test("the app's page says it is not available yet, and names no setting", async () => {
+ const view = renderAccount(false);
+ expect(
+ await view.findByText(/Gmail is not available on this OpenBot yet/),
+ ).toBeTruthy();
+ expect(view.queryByText(/COMPOSIO_API_KEY/)).toBeNull();
+ expect(view.queryByRole("button", { name: "Connect" })).toBeNull();
+});
+
+test("with the key set, the app's page offers the account as before", async () => {
+ const view = renderAccount(true);
+ expect(await view.findByText("Your account")).toBeTruthy();
+ expect(view.queryByText(/not available on this OpenBot yet/)).toBeNull();
+});
diff --git a/app/tests/tool-activity.test.tsx b/app/tests/tool-activity.test.tsx
index f395a81126..529e470613 100644
--- a/app/tests/tool-activity.test.tsx
+++ b/app/tests/tool-activity.test.tsx
@@ -169,6 +169,55 @@ test("a request to connect an app at all is never folded away either", () => {
expect(grouped.map((item) => item.id)).toContain("connect");
});
+test("a refusal that needs an administrator, or a lost account, is never folded away either", () => {
+ for (const reason of ["shared", "reconnect"]) {
+ const prompt = call(
+ reason,
+ "mcp__composio-gmail__GMAIL_FETCH_EMAILS",
+ {},
+ JSON.stringify(
+ `Refused. [connect-app]{"serverId":"composio-gmail","title":"Gmail","agentId":"expenses","reason":"${reason}"} Gmail needs an account.`,
+ ),
+ );
+ const grouped = groupToolSteps([search("a"), prompt, search("b")], drawn);
+ expect(grouped.map((item) => item.id)).toContain(reason);
+ }
+});
+
+test("the app-access card stays out of a group, even before its answer arrives", () => {
+ /*
+ * A server tool, so no renderer registers it and the registry cannot say it is drawn. Folded
+ * while it ran, the card would land inside "Used N tools" and then jump out once answered.
+ */
+ for (const result of [
+ undefined,
+ JSON.stringify(
+ '[connect-app]{"serverId":"composio-gmail","title":"Gmail","agentId":"expenses","reason":"access"} Gmail is switched off on this deployment.',
+ ),
+ ]) {
+ const card = call("card", "show_app_access", { app: "gmail" }, result);
+ const grouped = groupToolSteps([search("a"), card, search("b")], drawn);
+ expect(grouped.map((item) => item.id)).toEqual(["a", "card", "b"]);
+ }
+});
+
+test("show_app_access is named in words in a step's details too, and keeps its words to the model out", () => {
+ const running = stepDetails(call("card", "show_app_access", { app: "x" }));
+ expect(running.label).toBe("Checking app access");
+ const done = stepDetails(
+ call(
+ "card",
+ "show_app_access",
+ { app: "x" },
+ JSON.stringify(
+ "This deployment has no app called x, so there is nothing to show. Say so plainly; do not browse to it instead.",
+ ),
+ ),
+ );
+ expect(done.label).toBe("Checked app access");
+ expect(done.output).toBeUndefined();
+});
+
test("the summary says what was done, never which provider searched", () => {
const steps = [
search(),
diff --git a/app/tests/tool-name.test.ts b/app/tests/tool-name.test.ts
index 7a9c5bbf4d..2f67894305 100644
--- a/app/tests/tool-name.test.ts
+++ b/app/tests/tool-name.test.ts
@@ -148,3 +148,18 @@ describe("naming a tool call", () => {
});
});
});
+
+describe("this deployment's own tools", () => {
+ test("show_app_access is named for what it is doing, then for what it did", () => {
+ expect(readToolName("show_app_access", true)).toEqual({
+ label: "Checking app access",
+ });
+ expect(readToolName("show_app_access")).toEqual({
+ label: "Checked app access",
+ });
+ });
+
+ test("any other bare name is left exactly as it is", () => {
+ expect(readToolName("run_command", true)).toEqual({ label: "run_command" });
+ });
+});
diff --git a/docs/architecture.md b/docs/architecture.md
index 68248991e8..fa5c727431 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -313,6 +313,8 @@ A catalogue entry says whose credential a Bot reaches it with, which is a differ
Every MCP call checks the grant first, then evaluates the same action policy engine with MCP context, then audits the result.
+Whether a person can use an app through a Bot is answered once, on the server, from those same facts — the app's row, the Bot's grants, the person's own connection, a Shared app's team account — as one of six states: `ready`, `not_connected`, `reconnect`, `shared_not_connected`, `disabled` or `not_granted` (`server/src/plugins/app-access.ts`). The last three are an administrator's to fix, so a person can file a request (`app_access_requests`) for exactly what the state is missing, and approving one performs it — switching the app on, granting it to the Bot — through the same code paths the admin screens use. See [Composio](plugins/composio.md#what-an-app-is-for-one-person-on-one-bot).
+
### Writing a skill in a conversation
A skill can be written from the composer as well as from `/skills`. The deployment ships a skill called `skill-creator` whose instruction is how to interview somebody about the skill they want; a Bot holding it is also offered four tools the app registers — `list_skills`, `read_skill`, `list_skill_tools`, and `save_skill`, which suspends the run on a card showing the command, the title and the whole instruction. Nothing is written until the person presses the button.
diff --git a/docs/plugins/composio.md b/docs/plugins/composio.md
index 7b31b1a600..0e5afe5144 100644
--- a/docs/plugins/composio.md
+++ b/docs/plugins/composio.md
@@ -17,6 +17,8 @@ Setting it up takes three hands, and none of them can do another's:
| An administrator | Grants its actions to a Bot | `/admin/plugins/composio-`, then that page's per-Bot screen |
| Each person | Connects their own account to that app | `/settings/connected-accounts` |
| An administrator | Chooses Personal or Shared for an app, and connects a Shared app's account | `/admin/plugins/composio-` |
+| Each person | Asks for access to an app a Bot needs and only an administrator can fix | The Bot's page, or the card in a conversation |
+| An administrator | Approves or dismisses that request — approving does the change | `/admin/plugins/composio-` |
The key is the row that is easiest to misread, so it is stated twice: it is an environment variable
and nothing else. No administrator, however permissioned, can turn Composio on from a page, and
@@ -495,6 +497,85 @@ the ordering everywhere in this connector, so a failure between the two leaves a
than live and unreachable; pressing Disconnect again is the whole recovery. Audited as
`mcp.account_disconnected`.
+## What an app is for one person on one Bot
+
+Every app a Bot holds is in one of six states for the person looking, and the server decides which
+— from the app's row, the grants, the person's own connection and the team account — so the Bot's
+page and a card in a conversation say the same thing a call would find:
+
+| State | Means | Next step |
+| ---------------------- | -------------------------------------------------------------------------- | --------------------------------- |
+| `ready` | Usable now: their own account, a connected team account, or no account | None |
+| `not_connected` | Enabled and granted; used under each person's account, and theirs is not connected | **Connect**, the Settings flow |
+| `reconnect` | Connected once; the credential was withdrawn, or the vendor refused the key at the last check | **Reconnect** |
+| `shared_not_connected` | A Shared app whose team account is not connected, or whose key was refused | **Request access** |
+| `disabled` | Switched off: no row, no `COMPOSIO_API_KEY`, or something only an administrator can supply is missing | **Request access** |
+| `not_granted` | Enabled, and this Bot holds none of its actions | **Request access** |
+
+"Connected" is what this deployment last recorded — the same `verified` and probe columns the
+Connected accounts page reads. Nothing asks the vendor per page load, so an account ended in
+Composio's dashboard reads as `ready` until the next call or re-check finds out — and the call that
+finds out is refused with a card offering **Reconnect** (below).
+
+On the Bot's page, Connect opens the app's consent screen in a new tab through the same connect route
+Settings uses, and the row turns ready once the connection is recorded. An app connected with a typed
+key, and Reconnect on a Composio app (whose row has to be disconnected before a new account can be
+attached), go to the app's own page under `/settings/connected-accounts` instead.
+
+### In a conversation
+
+A Bot that needs an app it cannot use puts a card in the transcript naming the app, its state and the
+next step, drawn from the same state as the Bot's page:
+
+- a call refused because the person has not connected the app;
+- a call refused because a Shared app's team account is not connected — **Request access** rather
+ than the sentence "Ask an administrator to connect it.";
+- a call Composio answered with "no connected account" — **Reconnect**, or for a Shared app a line
+ saying an administrator has to connect it again;
+- the `show_app_access` tool, which every Bot holds and is told to call for an app it holds nothing
+ for, switched off or not granted — **Request access**.
+
+The card asks `GET /api/plugins/for/:agentId/apps/:app` for the Bot that made the call when it is
+drawn, so a card scrolled back to later shows what is true now. The one case it takes the refusal's
+word over the live state is the lost account, which the server cannot see; a connection made after
+the refusal ends that. The card is drawn in the transcript, never folded into the "Used N tools" line.
+
+`GET /api/plugins/for/:agentId/apps` lists every app the Bot holds, including a grant whose app is
+gone; `GET /api/plugins/for/:agentId/apps/:app` answers for one app by key or by name, held or not.
+Both answer only for a Bot the person may use. Settings still lists only enabled apps, and the
+directory is never listed to anybody but an administrator: an app nobody enabled appears only where
+a Bot needs it.
+
+### Requesting access
+
+Where only an administrator can fix it, **Request access** files a request. The person names the Bot
+and the app; what is asked for comes from the state — `enable` for `disabled`, `grant` for
+`not_granted`, `connect_shared` for `shared_not_connected` — so a request cannot ask for something
+the app does not need. Asking twice answers with the request already waiting: one pending request
+per person, app, Bot and kind, held by a unique index rather than a check, so two replicas cannot
+both file one. A person can withdraw their own.
+
+Requests wait on the app's own page under `/admin/plugins`, and all of them at the top of the
+Plugins list, with a count on each app's row — an app nobody has switched on has no page of its own. They
+show who asked and for which Bot — they asked — and nothing about anybody's connections.
+**Approve** does what was asked in the same press:
+
+- **enable** switches the app on through the same path as the directory's Add, then grants it to
+ the Bot;
+- **grant** grants the Bot every action the app advertises — or the ones the administrator picked —
+ with the Shared approval a grant from the app's page would record;
+- **connect_shared** is approved once the team account is connected, which only the consent screen
+ on the app's page can do.
+
+What only an administrator can type — a missing `COMPOSIO_API_KEY`, a curated connector's OAuth
+client or token — is refused with the step to take, and the request stays pending. **Dismiss**
+changes nothing but the request. Filing, withdrawing, approving, dismissing and every refusal are
+audited as `app_access.*`.
+
+This is a different thing from the Shared account requests above: those ask an administrator to
+approve who may use a team account that is already connected; these ask for the app, the grant or
+the account itself.
+
## The two paths that end somebody else's access
Both of these used to stop at this deployment's own tables, which was the only thing they could do
diff --git a/server/drizzle/0060_app_access_requests.sql b/server/drizzle/0060_app_access_requests.sql
new file mode 100644
index 0000000000..6de21e2346
--- /dev/null
+++ b/server/drizzle/0060_app_access_requests.sql
@@ -0,0 +1,19 @@
+CREATE TABLE "app_access_requests" (
+ "id" text PRIMARY KEY NOT NULL,
+ "requested_by" text NOT NULL,
+ "agent_id" text,
+ "app_key" text NOT NULL,
+ "app_title" text NOT NULL,
+ "kind" text NOT NULL,
+ "status" text DEFAULT 'pending' NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "resolved_at" timestamp with time zone,
+ "resolved_by" text,
+ CONSTRAINT "app_access_requests_kind_check" CHECK ("app_access_requests"."kind" IN ('enable', 'grant', 'connect_shared')),
+ CONSTRAINT "app_access_requests_status_check" CHECK ("app_access_requests"."status" IN ('pending', 'approved', 'dismissed', 'cancelled'))
+);
+--> statement-breakpoint
+ALTER TABLE "app_access_requests" ADD CONSTRAINT "app_access_requests_requested_by_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "app_access_requests" ADD CONSTRAINT "app_access_requests_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE UNIQUE INDEX "app_access_requests_pending_idx" ON "app_access_requests" USING btree ("requested_by","app_key",coalesce("agent_id", ''),"kind") WHERE "app_access_requests"."status" = 'pending';--> statement-breakpoint
+CREATE INDEX "app_access_requests_status_idx" ON "app_access_requests" USING btree ("status","app_key");
\ No newline at end of file
diff --git a/server/drizzle/meta/0060_snapshot.json b/server/drizzle/meta/0060_snapshot.json
new file mode 100644
index 0000000000..6f1ec67ae7
--- /dev/null
+++ b/server/drizzle/meta/0060_snapshot.json
@@ -0,0 +1,10176 @@
+{
+ "id": "19f75c87-43a6-43f8-85f9-e930e5adaab4",
+ "prevId": "53b701d2-39fe-47c6-aabb-5be79754acb9",
+ "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.attachments": {
+ "name": "attachments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bytes": {
+ "name": "bytes",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "attached_at": {
+ "name": "attached_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "upload_group": {
+ "name": "upload_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "attachments_channel_idx": {
+ "name": "attachments_channel_idx",
+ "columns": [
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "attachments_uploaded_by_idx": {
+ "name": "attachments_uploaded_by_idx",
+ "columns": [
+ {
+ "expression": "uploaded_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "attachments_upload_group_idx": {
+ "name": "attachments_upload_group_idx",
+ "columns": [
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "uploaded_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "upload_group",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"attachments\".\"attached_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "attachments_staged_idx": {
+ "name": "attachments_staged_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"attachments\".\"attached_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "attachments_channel_id_channels_id_fk": {
+ "name": "attachments_channel_id_channels_id_fk",
+ "tableFrom": "attachments",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "attachments_uploaded_by_users_id_fk": {
+ "name": "attachments_uploaded_by_users_id_fk",
+ "tableFrom": "attachments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "uploaded_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "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
+ },
+ "initiator_kind": {
+ "name": "initiator_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'person'"
+ },
+ "initiator_id": {
+ "name": "initiator_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": {}
+ },
+ "audit_events_initiator_time_idx": {
+ "name": "audit_events_initiator_time_idx",
+ "columns": [
+ {
+ "expression": "initiator_kind",
+ "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
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_read_at": {
+ "name": "last_read_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": {
+ "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
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary_at": {
+ "name": "summary_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message": {
+ "name": "last_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_source_id": {
+ "name": "last_message_source_id",
+ "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
+ },
+ "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": {
+ "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": {}
+ },
+ "channels_awaiting_summary_idx": {
+ "name": "channels_awaiting_summary_idx",
+ "columns": [
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"channels\".\"summary\" is null and \"channels\".\"deleted_at\" is null",
+ "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.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": {
+ "credentials_active_key_idx": {
+ "name": "credentials_active_key_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"credentials\".\"revoked_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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.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.user_instructions": {
+ "name": "user_instructions",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "instructions": {
+ "name": "instructions",
+ "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": {},
+ "foreignKeys": {
+ "user_instructions_user_id_users_id_fk": {
+ "name": "user_instructions_user_id_users_id_fk",
+ "tableFrom": "user_instructions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_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
+ },
+ "preferences": {
+ "name": "preferences",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "onboarding_step": {
+ "name": "onboarding_step",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_signed_in_at": {
+ "name": "last_signed_in_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": {
+ "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.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_page_frame": {
+ "name": "computer_page_frame",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_call_id": {
+ "name": "tool_call_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "frame": {
+ "name": "frame",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "captured_at": {
+ "name": "captured_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "computer_page_frame_captured_idx": {
+ "name": "computer_page_frame_captured_idx",
+ "columns": [
+ {
+ "expression": "captured_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "computer_page_frame_computer_id_tool_call_id_pk": {
+ "name": "computer_page_frame_computer_id_tool_call_id_pk",
+ "columns": [
+ "computer_id",
+ "tool_call_id"
+ ]
+ }
+ },
+ "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()"
+ },
+ "session": {
+ "name": "session",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "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
+ },
+ "pinned_at": {
+ "name": "pinned_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.routine_runs": {
+ "name": "routine_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "routine_run_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "waiting": {
+ "name": "waiting",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'schedule'"
+ }
+ },
+ "indexes": {
+ "routine_runs_by_routine_idx": {
+ "name": "routine_runs_by_routine_idx",
+ "columns": [
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routine_runs_routine_id_routines_id_fk": {
+ "name": "routine_runs_routine_id_routines_id_fk",
+ "tableFrom": "routine_runs",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routine_sweeps": {
+ "name": "routine_sweeps",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "swept_at": {
+ "name": "swept_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "owner": {
+ "name": "owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routines": {
+ "name": "routines",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instruction": {
+ "name": "instruction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cron": {
+ "name": "cron",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled_at": {
+ "name": "enabled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "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": {
+ "routines_due_idx": {
+ "name": "routines_due_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routines_by_owner_idx": {
+ "name": "routines_by_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routines_owner_user_id_users_id_fk": {
+ "name": "routines_owner_user_id_users_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routines_agent_id_agents_id_fk": {
+ "name": "routines_agent_id_agents_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "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.app_access_requests": {
+ "name": "app_access_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "app_key": {
+ "name": "app_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "app_title": {
+ "name": "app_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by": {
+ "name": "resolved_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "app_access_requests_pending_idx": {
+ "name": "app_access_requests_pending_idx",
+ "columns": [
+ {
+ "expression": "requested_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "app_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "coalesce(\"agent_id\", '')",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"app_access_requests\".\"status\" = 'pending'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "app_access_requests_status_idx": {
+ "name": "app_access_requests_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "app_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "app_access_requests_requested_by_users_id_fk": {
+ "name": "app_access_requests_requested_by_users_id_fk",
+ "tableFrom": "app_access_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "requested_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "app_access_requests_agent_id_agents_id_fk": {
+ "name": "app_access_requests_agent_id_agents_id_fk",
+ "tableFrom": "app_access_requests",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "app_access_requests_kind_check": {
+ "name": "app_access_requests_kind_check",
+ "value": "\"app_access_requests\".\"kind\" IN ('enable', 'grant', 'connect_shared')"
+ },
+ "app_access_requests_status_check": {
+ "name": "app_access_requests_status_check",
+ "value": "\"app_access_requests\".\"status\" IN ('pending', 'approved', 'dismissed', 'cancelled')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.brokered_connections": {
+ "name": "brokered_connections",
+ "schema": "",
+ "columns": {
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "app": {
+ "name": "app",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "holder": {
+ "name": "holder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "vendor_user_id": {
+ "name": "vendor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_by": {
+ "name": "connected_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "probe_action": {
+ "name": "probe_action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "brokered_connections_person_idx": {
+ "name": "brokered_connections_person_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "app",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"brokered_connections\".\"holder\" = 'person'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "brokered_connections_deployment_idx": {
+ "name": "brokered_connections_deployment_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "app",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"brokered_connections\".\"holder\" = 'deployment'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "brokered_connections_user_idx": {
+ "name": "brokered_connections_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "brokered_connections_holder_check": {
+ "name": "brokered_connections_holder_check",
+ "value": "(\"brokered_connections\".\"holder\" = 'person' AND \"brokered_connections\".\"user_id\" IS NOT NULL) OR (\"brokered_connections\".\"holder\" = 'deployment' AND \"brokered_connections\".\"user_id\" IS NULL)"
+ },
+ "brokered_connections_provider_check": {
+ "name": "brokered_connections_provider_check",
+ "value": "\"brokered_connections\".\"provider\" IN ('composio', 'arcade')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.composio_connections": {
+ "name": "composio_connections",
+ "schema": "",
+ "columns": {
+ "toolkit": {
+ "name": "toolkit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "probe_action": {
+ "name": "probe_action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "composio_connections_user_idx": {
+ "name": "composio_connections_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "composio_connections_toolkit_user_id_pk": {
+ "name": "composio_connections_toolkit_user_id_pk",
+ "columns": [
+ "toolkit",
+ "user_id"
+ ]
+ }
+ },
+ "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
+ },
+ "logo": {
+ "name": "logo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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
+ },
+ "auth_scheme": {
+ "name": "auth_scheme",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "account_mode": {
+ "name": "account_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "shared_vendor_user_id": {
+ "name": "shared_vendor_user_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
+ },
+ "skipped_tools": {
+ "name": "skipped_tools",
+ "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": {
+ "mcp_servers_account_mode_check": {
+ "name": "mcp_servers_account_mode_check",
+ "value": "\"mcp_servers\".\"account_mode\" IS NULL OR \"mcp_servers\".\"account_mode\" IN ('personal', 'shared')"
+ }
+ },
+ "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
+ },
+ "vendor_name": {
+ "name": "vendor_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "effect": {
+ "name": "effect",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "destructive": {
+ "name": "destructive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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.pending_authorizations": {
+ "name": "pending_authorizations",
+ "schema": "",
+ "columns": {
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "holder": {
+ "name": "holder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "vendor_user_id": {
+ "name": "vendor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth_id": {
+ "name": "auth_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_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
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "pending_authorizations_server_idx": {
+ "name": "pending_authorizations_server_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pending_authorizations_actor_id_users_id_fk": {
+ "name": "pending_authorizations_actor_id_users_id_fk",
+ "tableFrom": "pending_authorizations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pending_authorizations_server_id_mcp_servers_id_fk": {
+ "name": "pending_authorizations_server_id_mcp_servers_id_fk",
+ "tableFrom": "pending_authorizations",
+ "tableTo": "mcp_servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "pending_authorizations_holder_check": {
+ "name": "pending_authorizations_holder_check",
+ "value": "(\"pending_authorizations\".\"holder\" = 'person' AND \"pending_authorizations\".\"user_id\" IS NOT NULL) OR (\"pending_authorizations\".\"holder\" = 'deployment' AND \"pending_authorizations\".\"user_id\" IS NULL)"
+ },
+ "pending_authorizations_outcome_check": {
+ "name": "pending_authorizations_outcome_check",
+ "value": "\"pending_authorizations\".\"outcome\" IS NULL OR \"pending_authorizations\".\"outcome\" IN ('completed', 'failed', 'unreconciled', 'withdrawn')"
+ },
+ "pending_authorizations_completion_check": {
+ "name": "pending_authorizations_completion_check",
+ "value": "(\"pending_authorizations\".\"completed_at\" IS NULL) = (\"pending_authorizations\".\"outcome\" IS NULL)"
+ },
+ "pending_authorizations_user_is_actor_check": {
+ "name": "pending_authorizations_user_is_actor_check",
+ "value": "\"pending_authorizations\".\"user_id\" IS NULL OR \"pending_authorizations\".\"user_id\" = \"pending_authorizations\".\"actor_id\""
+ }
+ },
+ "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.skill_tools": {
+ "name": "skill_tools",
+ "schema": "",
+ "columns": {
+ "skill_id": {
+ "name": "skill_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "declared_by": {
+ "name": "declared_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skill_tools_ref_idx": {
+ "name": "skill_tools_ref_idx",
+ "columns": [
+ {
+ "expression": "ref",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "skill_tools_skill_id_skills_id_fk": {
+ "name": "skill_tools_skill_id_skills_id_fk",
+ "tableFrom": "skill_tools",
+ "tableTo": "skills",
+ "columnsFrom": [
+ "skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "skill_tools_skill_id_ref_pk": {
+ "name": "skill_tools_skill_id_ref_pk",
+ "columns": [
+ "skill_id",
+ "ref"
+ ]
+ }
+ },
+ "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
+ },
+ "public.work_items": {
+ "name": "work_items",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_at": {
+ "name": "run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "claimed_by": {
+ "name": "claimed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "work_items_claimable_idx": {
+ "name": "work_items_claimable_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "work_items_kind_key_pk": {
+ "name": "work_items_kind_key_pk",
+ "columns": [
+ "kind",
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.voice_sessions": {
+ "name": "voice_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "anchor_message_id": {
+ "name": "anchor_message_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transcript": {
+ "name": "transcript",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary_status": {
+ "name": "summary_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'failed'"
+ }
+ },
+ "indexes": {
+ "voice_sessions_channel_started_idx": {
+ "name": "voice_sessions_channel_started_idx",
+ "columns": [
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "voice_sessions_channel_id_channels_id_fk": {
+ "name": "voice_sessions_channel_id_channels_id_fk",
+ "tableFrom": "voice_sessions",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "voice_sessions_user_id_users_id_fk": {
+ "name": "voice_sessions_user_id_users_id_fk",
+ "tableFrom": "voice_sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "voice_sessions_duration_check": {
+ "name": "voice_sessions_duration_check",
+ "value": "\"voice_sessions\".\"duration_seconds\" >= 0"
+ },
+ "voice_sessions_summary_check": {
+ "name": "voice_sessions_summary_check",
+ "value": "(\"voice_sessions\".\"summary_status\" = 'ready' AND \"voice_sessions\".\"summary\" IS NOT NULL) OR (\"voice_sessions\".\"summary_status\" = 'failed' AND \"voice_sessions\".\"summary\" IS NULL)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.learning_settings": {
+ "name": "learning_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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.learning_thread_bindings": {
+ "name": "learning_thread_bindings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "container_id": {
+ "name": "container_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "learning_thread_bindings_user_id_thread_id_pk": {
+ "name": "learning_thread_bindings_user_id_thread_id_pk",
+ "columns": [
+ "user_id",
+ "thread_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.responsibilities": {
+ "name": "responsibilities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_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
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instruction": {
+ "name": "instruction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "success_criteria": {
+ "name": "success_criteria",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "responsibility_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "progress": {
+ "name": "progress",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "last_result": {
+ "name": "last_result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subscriptions": {
+ "name": "subscriptions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "completed_at": {
+ "name": "completed_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": {
+ "responsibilities_owner_status_idx": {
+ "name": "responsibilities_owner_status_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "responsibilities_owner_user_id_users_id_fk": {
+ "name": "responsibilities_owner_user_id_users_id_fk",
+ "tableFrom": "responsibilities",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "responsibilities_agent_id_agents_id_fk": {
+ "name": "responsibilities_agent_id_agents_id_fk",
+ "tableFrom": "responsibilities",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.responsibility_events": {
+ "name": "responsibility_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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": {
+ "responsibility_events_source_id_idx": {
+ "name": "responsibility_events_source_id_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "responsibility_events_owner_user_id_users_id_fk": {
+ "name": "responsibility_events_owner_user_id_users_id_fk",
+ "tableFrom": "responsibility_events",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.responsibility_runs": {
+ "name": "responsibility_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "responsibility_id": {
+ "name": "responsibility_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "responsibility_run_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "reply_text": {
+ "name": "reply_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "waiting": {
+ "name": "waiting",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "continuation": {
+ "name": "continuation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "finished_at": {
+ "name": "finished_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": {
+ "responsibility_runs_event_idx": {
+ "name": "responsibility_runs_event_idx",
+ "columns": [
+ {
+ "expression": "responsibility_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "responsibility_runs_status_idx": {
+ "name": "responsibility_runs_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "responsibility_runs_responsibility_id_responsibilities_id_fk": {
+ "name": "responsibility_runs_responsibility_id_responsibilities_id_fk",
+ "tableFrom": "responsibility_runs",
+ "tableTo": "responsibilities",
+ "columnsFrom": [
+ "responsibility_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "responsibility_runs_event_id_responsibility_events_id_fk": {
+ "name": "responsibility_runs_event_id_responsibility_events_id_fk",
+ "tableFrom": "responsibility_runs",
+ "tableTo": "responsibility_events",
+ "columnsFrom": [
+ "event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.responsibility_source_bindings": {
+ "name": "responsibility_source_bindings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "repository": {
+ "name": "repository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "responsibility_source_bindings_owner_idx": {
+ "name": "responsibility_source_bindings_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "responsibility_source_bindings_owner_user_id_users_id_fk": {
+ "name": "responsibility_source_bindings_owner_user_id_users_id_fk",
+ "tableFrom": "responsibility_source_bindings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "responsibility_source_bindings_credential_id_credentials_id_fk": {
+ "name": "responsibility_source_bindings_credential_id_credentials_id_fk",
+ "tableFrom": "responsibility_source_bindings",
+ "tableTo": "credentials",
+ "columnsFrom": [
+ "credential_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.responsibility_triggers": {
+ "name": "responsibility_triggers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "responsibility_id": {
+ "name": "responsibility_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 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": {
+ "responsibility_triggers_owner_idx": {
+ "name": "responsibility_triggers_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "responsibility_triggers_responsibility_idx": {
+ "name": "responsibility_triggers_responsibility_idx",
+ "columns": [
+ {
+ "expression": "responsibility_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "responsibility_triggers_kind_idx": {
+ "name": "responsibility_triggers_kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "responsibility_triggers_owner_user_id_users_id_fk": {
+ "name": "responsibility_triggers_owner_user_id_users_id_fk",
+ "tableFrom": "responsibility_triggers",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "responsibility_triggers_responsibility_id_responsibilities_id_fk": {
+ "name": "responsibility_triggers_responsibility_id_responsibilities_id_fk",
+ "tableFrom": "responsibility_triggers",
+ "tableTo": "responsibilities",
+ "columnsFrom": [
+ "responsibility_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "responsibility_triggers_credential_id_credentials_id_fk": {
+ "name": "responsibility_triggers_credential_id_credentials_id_fk",
+ "tableFrom": "responsibility_triggers",
+ "tableTo": "credentials",
+ "columnsFrom": [
+ "credential_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.approval_preferences": {
+ "name": "approval_preferences",
+ "schema": "",
+ "columns": {
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "auto_review": {
+ "name": "auto_review",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "host_commands": {
+ "name": "host_commands",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ask'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "approval_preferences_owner_user_id_users_id_fk": {
+ "name": "approval_preferences_owner_user_id_users_id_fk",
+ "tableFrom": "approval_preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "approval_preferences_host_commands_check": {
+ "name": "approval_preferences_host_commands_check",
+ "value": "\"approval_preferences\".\"host_commands\" IN ('ask', 'allow', 'never')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.approval_requests": {
+ "name": "approval_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_call_id": {
+ "name": "tool_call_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action_digest": {
+ "name": "action_digest",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "decision": {
+ "name": "decision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result": {
+ "name": "result",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consumed_at": {
+ "name": "consumed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "approval_requests_action_idx": {
+ "name": "approval_requests_action_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tool_call_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "approval_requests_inbox_idx": {
+ "name": "approval_requests_inbox_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "approval_requests_owner_user_id_users_id_fk": {
+ "name": "approval_requests_owner_user_id_users_id_fk",
+ "tableFrom": "approval_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "approval_status_check": {
+ "name": "approval_status_check",
+ "value": "\"approval_requests\".\"status\" IN ('pending', 'approved', 'denied', 'consumed', 'completed')"
+ },
+ "approval_decision_check": {
+ "name": "approval_decision_check",
+ "value": "\"approval_requests\".\"decision\" IN ('allow_once', 'allow_always', 'deny', 'handled')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.approval_rules": {
+ "name": "approval_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bot_id": {
+ "name": "bot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_ref": {
+ "name": "tool_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "effect": {
+ "name": "effect",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "behaviour": {
+ "name": "behaviour",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'allow'"
+ },
+ "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()"
+ }
+ },
+ "indexes": {
+ "approval_rules_owner_idx": {
+ "name": "approval_rules_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "approval_rules_owner_user_id_users_id_fk": {
+ "name": "approval_rules_owner_user_id_users_id_fk",
+ "tableFrom": "approval_rules",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "approval_rules_behaviour_check": {
+ "name": "approval_rules_behaviour_check",
+ "value": "\"approval_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.approval_team_rules": {
+ "name": "approval_team_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "bot_id": {
+ "name": "bot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_ref": {
+ "name": "tool_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "effect": {
+ "name": "effect",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "behaviour": {
+ "name": "behaviour",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "approval_team_rules_created_by_users_id_fk": {
+ "name": "approval_team_rules_created_by_users_id_fk",
+ "tableFrom": "approval_team_rules",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "approval_team_rules_behaviour_check": {
+ "name": "approval_team_rules_behaviour_check",
+ "value": "\"approval_team_rules\".\"behaviour\" IN ('allow', 'pre_approved', 'ask', 'hand_off')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.approval_team_settings": {
+ "name": "approval_team_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enforce_auto_review": {
+ "name": "enforce_auto_review",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "custom_rules_enabled": {
+ "name": "custom_rules_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "host_commands_cap": {
+ "name": "host_commands_cap",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'allow'"
+ },
+ "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": {
+ "approval_team_settings_updated_by_users_id_fk": {
+ "name": "approval_team_settings_updated_by_users_id_fk",
+ "tableFrom": "approval_team_settings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "updated_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "approval_team_settings_host_commands_cap_check": {
+ "name": "approval_team_settings_host_commands_cap_check",
+ "value": "\"approval_team_settings\".\"host_commands_cap\" IN ('ask', 'allow', 'never')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.memory_sources": {
+ "name": "memory_sources",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_ref": {
+ "name": "tool_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "args": {
+ "name": "args",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "sync_status": {
+ "name": "sync_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "sync_error": {
+ "name": "sync_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sync_at": {
+ "name": "last_sync_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_sync_at": {
+ "name": "next_sync_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "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": {
+ "memory_sources_owner_idx": {
+ "name": "memory_sources_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "memory_sources_due_idx": {
+ "name": "memory_sources_due_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_sync_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "memory_sources_owner_user_id_users_id_fk": {
+ "name": "memory_sources_owner_user_id_users_id_fk",
+ "tableFrom": "memory_sources",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "memory_sources_agent_id_agents_id_fk": {
+ "name": "memory_sources_agent_id_agents_id_fk",
+ "tableFrom": "memory_sources",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.personal_memories": {
+ "name": "personal_memories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "import_digest": {
+ "name": "import_digest",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provenance": {
+ "name": "provenance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "review_state": {
+ "name": "review_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unreviewed'"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "formed_by": {
+ "name": "formed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'person'"
+ },
+ "formed_by_agent_id": {
+ "name": "formed_by_agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_app": {
+ "name": "source_app",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_ref": {
+ "name": "source_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_link": {
+ "name": "source_link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "observed_at": {
+ "name": "observed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "intelligence_memory_id": {
+ "name": "intelligence_memory_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "intelligence_digest": {
+ "name": "intelligence_digest",
+ "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": {
+ "personal_memories_owner_idx": {
+ "name": "personal_memories_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "personal_memories_intelligence_idx": {
+ "name": "personal_memories_intelligence_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "intelligence_memory_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "personal_memories_formed_idx": {
+ "name": "personal_memories_formed_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "formed_by_agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "personal_memories_import_idx": {
+ "name": "personal_memories_import_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "personal_memories_formed_digest_idx": {
+ "name": "personal_memories_formed_digest_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "import_digest",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"personal_memories\".\"formed_by\" = 'bot'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "personal_memories_owner_user_id_users_id_fk": {
+ "name": "personal_memories_owner_user_id_users_id_fk",
+ "tableFrom": "personal_memories",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "personal_memories_source_id_memory_sources_id_fk": {
+ "name": "personal_memories_source_id_memory_sources_id_fk",
+ "tableFrom": "personal_memories",
+ "tableTo": "memory_sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "personal_memories_formed_by_agent_id_agents_id_fk": {
+ "name": "personal_memories_formed_by_agent_id_agents_id_fk",
+ "tableFrom": "personal_memories",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "formed_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.default_opt_outs": {
+ "name": "default_opt_outs",
+ "schema": "",
+ "columns": {
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "feature": {
+ "name": "feature",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "default_opt_outs_owner_user_id_users_id_fk": {
+ "name": "default_opt_outs_owner_user_id_users_id_fk",
+ "tableFrom": "default_opt_outs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "default_opt_outs_agent_id_agents_id_fk": {
+ "name": "default_opt_outs_agent_id_agents_id_fk",
+ "tableFrom": "default_opt_outs",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "default_opt_outs_owner_user_id_agent_id_feature_scope_pk": {
+ "name": "default_opt_outs_owner_user_id_agent_id_feature_scope_pk",
+ "columns": [
+ "owner_user_id",
+ "agent_id",
+ "feature",
+ "scope"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "default_opt_outs_feature_check": {
+ "name": "default_opt_outs_feature_check",
+ "value": "\"default_opt_outs\".\"feature\" IN ('memory_source', 'proactive_research')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.proactive_settings": {
+ "name": "proactive_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_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
+ },
+ "focus": {
+ "name": "focus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "interval_minutes": {
+ "name": "interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 240
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "last_error": {
+ "name": "last_error",
+ "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": {
+ "proactive_settings_owner_agent_idx": {
+ "name": "proactive_settings_owner_agent_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "proactive_settings_due_idx": {
+ "name": "proactive_settings_due_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "proactive_settings_owner_user_id_users_id_fk": {
+ "name": "proactive_settings_owner_user_id_users_id_fk",
+ "tableFrom": "proactive_settings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "proactive_settings_agent_id_agents_id_fk": {
+ "name": "proactive_settings_agent_id_agents_id_fk",
+ "tableFrom": "proactive_settings",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "proactive_settings_interval_check": {
+ "name": "proactive_settings_interval_check",
+ "value": "\"proactive_settings\".\"interval_minutes\" >= 60 AND \"proactive_settings\".\"interval_minutes\" <= 1440"
+ },
+ "proactive_settings_status_check": {
+ "name": "proactive_settings_status_check",
+ "value": "\"proactive_settings\".\"last_status\" IN ('idle', 'running', 'succeeded', 'error')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.proactive_suggestions": {
+ "name": "proactive_suggestions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "setting_id": {
+ "name": "setting_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_app": {
+ "name": "source_app",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_ref": {
+ "name": "source_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_link": {
+ "name": "source_link",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "delivered_at": {
+ "name": "delivered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_at": {
+ "name": "resolved_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": {
+ "proactive_suggestions_owner_idx": {
+ "name": "proactive_suggestions_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "proactive_suggestions_run_idx": {
+ "name": "proactive_suggestions_run_idx",
+ "columns": [
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "proactive_suggestions_owner_user_id_users_id_fk": {
+ "name": "proactive_suggestions_owner_user_id_users_id_fk",
+ "tableFrom": "proactive_suggestions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "proactive_suggestions_agent_id_agents_id_fk": {
+ "name": "proactive_suggestions_agent_id_agents_id_fk",
+ "tableFrom": "proactive_suggestions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "proactive_suggestions_setting_id_proactive_settings_id_fk": {
+ "name": "proactive_suggestions_setting_id_proactive_settings_id_fk",
+ "tableFrom": "proactive_suggestions",
+ "tableTo": "proactive_settings",
+ "columnsFrom": [
+ "setting_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "proactive_suggestions_status_check": {
+ "name": "proactive_suggestions_status_check",
+ "value": "\"proactive_suggestions\".\"status\" IN ('open', 'dismissed', 'started')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.demonstrations": {
+ "name": "demonstrations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bot_id": {
+ "name": "bot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'recording'"
+ },
+ "actions": {
+ "name": "actions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "draft": {
+ "name": "draft",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "skill_slug": {
+ "name": "skill_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "demonstrations_owner_idx": {
+ "name": "demonstrations_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "demonstrations_active_bot_idx": {
+ "name": "demonstrations_active_bot_idx",
+ "columns": [
+ {
+ "expression": "bot_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"demonstrations\".\"status\" = 'recording'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "demonstrations_owner_user_id_users_id_fk": {
+ "name": "demonstrations_owner_user_id_users_id_fk",
+ "tableFrom": "demonstrations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "demonstrations_bot_id_agents_id_fk": {
+ "name": "demonstrations_bot_id_agents_id_fk",
+ "tableFrom": "demonstrations",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "bot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.delivery_bindings": {
+ "name": "delivery_bindings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transport": {
+ "name": "transport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "identity": {
+ "name": "identity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "realm": {
+ "name": "realm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address": {
+ "name": "address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mention_id": {
+ "name": "mention_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "opted_out_at": {
+ "name": "opted_out_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": {
+ "delivery_binding_identity_idx": {
+ "name": "delivery_binding_identity_idx",
+ "columns": [
+ {
+ "expression": "transport",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "realm",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "identity",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "delivery_bindings_owner_idx": {
+ "name": "delivery_bindings_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "delivery_bindings_owner_user_id_users_id_fk": {
+ "name": "delivery_bindings_owner_user_id_users_id_fk",
+ "tableFrom": "delivery_bindings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "delivery_bindings_channel_id_channels_id_fk": {
+ "name": "delivery_bindings_channel_id_channels_id_fk",
+ "tableFrom": "delivery_bindings",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "delivery_bindings_agent_id_agents_id_fk": {
+ "name": "delivery_bindings_agent_id_agents_id_fk",
+ "tableFrom": "delivery_bindings",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.delivery_challenges": {
+ "name": "delivery_challenges",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transport": {
+ "name": "transport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "address": {
+ "name": "address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "consumed_at": {
+ "name": "consumed_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": {
+ "delivery_challenges_owner_user_id_users_id_fk": {
+ "name": "delivery_challenges_owner_user_id_users_id_fk",
+ "tableFrom": "delivery_challenges",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "delivery_challenges_channel_id_channels_id_fk": {
+ "name": "delivery_challenges_channel_id_channels_id_fk",
+ "tableFrom": "delivery_challenges",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "delivery_challenges_agent_id_agents_id_fk": {
+ "name": "delivery_challenges_agent_id_agents_id_fk",
+ "tableFrom": "delivery_challenges",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.delivery_inbox": {
+ "name": "delivery_inbox",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "binding_id": {
+ "name": "binding_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "realm": {
+ "name": "realm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "delivery_inbox_dedupe_idx": {
+ "name": "delivery_inbox_dedupe_idx",
+ "columns": [
+ {
+ "expression": "source",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "realm",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "delivery_inbox_owner_idx": {
+ "name": "delivery_inbox_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "delivery_inbox_owner_user_id_users_id_fk": {
+ "name": "delivery_inbox_owner_user_id_users_id_fk",
+ "tableFrom": "delivery_inbox",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "delivery_inbox_channel_id_channels_id_fk": {
+ "name": "delivery_inbox_channel_id_channels_id_fk",
+ "tableFrom": "delivery_inbox",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "delivery_inbox_agent_id_agents_id_fk": {
+ "name": "delivery_inbox_agent_id_agents_id_fk",
+ "tableFrom": "delivery_inbox",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.delivery_outbox": {
+ "name": "delivery_outbox",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dedupe_key": {
+ "name": "dedupe_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "binding_id": {
+ "name": "binding_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transport": {
+ "name": "transport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "delivery_outbox_owner_idx": {
+ "name": "delivery_outbox_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "delivery_outbox_provider_idx": {
+ "name": "delivery_outbox_provider_idx",
+ "columns": [
+ {
+ "expression": "transport",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "delivery_outbox_owner_user_id_users_id_fk": {
+ "name": "delivery_outbox_owner_user_id_users_id_fk",
+ "tableFrom": "delivery_outbox",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "delivery_outbox_channel_id_channels_id_fk": {
+ "name": "delivery_outbox_channel_id_channels_id_fk",
+ "tableFrom": "delivery_outbox",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "delivery_outbox_agent_id_agents_id_fk": {
+ "name": "delivery_outbox_agent_id_agents_id_fk",
+ "tableFrom": "delivery_outbox",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "delivery_outbox_dedupe_key_unique": {
+ "name": "delivery_outbox_dedupe_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "dedupe_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.push_devices": {
+ "name": "push_devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "platform": {
+ "name": "platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "push_devices_owner_idx": {
+ "name": "push_devices_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "push_devices_owner_user_id_users_id_fk": {
+ "name": "push_devices_owner_user_id_users_id_fk",
+ "tableFrom": "push_devices",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "push_devices_token_unique": {
+ "name": "push_devices_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group_bot_threads": {
+ "name": "group_bot_threads",
+ "schema": "",
+ "columns": {
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_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()"
+ }
+ },
+ "indexes": {
+ "group_bot_threads_thread_idx": {
+ "name": "group_bot_threads_thread_idx",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "group_bot_threads_owner_user_id_users_id_fk": {
+ "name": "group_bot_threads_owner_user_id_users_id_fk",
+ "tableFrom": "group_bot_threads",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "group_bot_threads_channel_id_channels_id_fk": {
+ "name": "group_bot_threads_channel_id_channels_id_fk",
+ "tableFrom": "group_bot_threads",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_bot_threads_owner_user_id_channel_id_agent_id_pk": {
+ "name": "group_bot_threads_owner_user_id_channel_id_agent_id_pk",
+ "columns": [
+ "owner_user_id",
+ "channel_id",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group_messages": {
+ "name": "group_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'completed'"
+ },
+ "details": {
+ "name": "details",
+ "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": {
+ "group_messages_channel_idx": {
+ "name": "group_messages_channel_idx",
+ "columns": [
+ {
+ "expression": "channel_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "group_messages_channel_id_channels_id_fk": {
+ "name": "group_messages_channel_id_channels_id_fk",
+ "tableFrom": "group_messages",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "group_messages_owner_user_id_users_id_fk": {
+ "name": "group_messages_owner_user_id_users_id_fk",
+ "tableFrom": "group_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "group_messages_status_check": {
+ "name": "group_messages_status_check",
+ "value": "\"group_messages\".\"status\" IN ('queued', 'running', 'waiting', 'completed', 'failed')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.action_records": {
+ "name": "action_records",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "surface": {
+ "name": "surface",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bot_id": {
+ "name": "bot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tool_name": {
+ "name": "tool_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "action_records_created_idx": {
+ "name": "action_records_created_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "action_records_bot_idx": {
+ "name": "action_records_bot_idx",
+ "columns": [
+ {
+ "expression": "bot_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "action_records_surface_check": {
+ "name": "action_records_surface_check",
+ "value": "\"action_records\".\"surface\" IN ('cloud', 'local')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.capability_settings": {
+ "name": "capability_settings",
+ "schema": "",
+ "columns": {
+ "scope_kind": {
+ "name": "scope_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "capability": {
+ "name": "capability",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allowed": {
+ "name": "allowed",
+ "type": "boolean",
+ "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": {
+ "capability_settings_scope_kind_scope_id_capability_pk": {
+ "name": "capability_settings_scope_kind_scope_id_capability_pk",
+ "columns": [
+ "scope_kind",
+ "scope_id",
+ "capability"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "capability_settings_scope_kind_check": {
+ "name": "capability_settings_scope_kind_check",
+ "value": "\"capability_settings\".\"scope_kind\" IN ('organization', 'role', 'group')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.enterprise_settings": {
+ "name": "enterprise_settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "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.model_usage": {
+ "name": "model_usage",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allowed": {
+ "name": "allowed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "model_usage_created_idx": {
+ "name": "model_usage_created_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "model_usage_model_idx": {
+ "name": "model_usage_model_idx",
+ "columns": [
+ {
+ "expression": "model",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "model_usage_source_check": {
+ "name": "model_usage_source_check",
+ "value": "\"model_usage\".\"source\" IN ('configured', 'observed', 'unknown')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.network_policies": {
+ "name": "network_policies",
+ "schema": "",
+ "columns": {
+ "scope_kind": {
+ "name": "scope_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rules": {
+ "name": "rules",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"entries\":[]}'::jsonb"
+ },
+ "locked": {
+ "name": "locked",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "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": {
+ "network_policies_scope_kind_scope_id_pk": {
+ "name": "network_policies_scope_kind_scope_id_pk",
+ "columns": [
+ "scope_kind",
+ "scope_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "network_policies_scope_kind_check": {
+ "name": "network_policies_scope_kind_check",
+ "value": "\"network_policies\".\"scope_kind\" IN ('organization', 'group')"
+ },
+ "network_policies_mode_check": {
+ "name": "network_policies_mode_check",
+ "value": "\"network_policies\".\"mode\" IN ('allow_all', 'defaults_plus_allowlist', 'allowlist_only')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.scim_connection_bindings": {
+ "name": "scim_connection_bindings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_key": {
+ "name": "connection_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provisioning_domain_id": {
+ "name": "provisioning_domain_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "decommissioned_at": {
+ "name": "decommissioned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decommission_status": {
+ "name": "decommission_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "decommission_cursor_user_id": {
+ "name": "decommission_cursor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decommission_reconciled_user_count": {
+ "name": "decommission_reconciled_user_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "decommission_batch_count": {
+ "name": "decommission_batch_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "decommission_revision": {
+ "name": "decommission_revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "decommission_completed_at": {
+ "name": "decommission_completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decommission_lease_id": {
+ "name": "decommission_lease_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decommission_lease_expires_at": {
+ "name": "decommission_lease_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "scim_connection_bindings_connection_idx": {
+ "name": "scim_connection_bindings_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "scim_connection_bindings_connection_key_unique": {
+ "name": "scim_connection_bindings_connection_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "connection_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scim_group_members": {
+ "name": "scim_group_members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scim_user_id": {
+ "name": "scim_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "membership_key": {
+ "name": "membership_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "scim_group_members_connection_idx": {
+ "name": "scim_group_members_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_group_members_group_idx": {
+ "name": "scim_group_members_group_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_group_members_scim_user_idx": {
+ "name": "scim_group_members_scim_user_idx",
+ "columns": [
+ {
+ "expression": "scim_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "scim_group_members_group_id_scim_groups_id_fk": {
+ "name": "scim_group_members_group_id_scim_groups_id_fk",
+ "tableFrom": "scim_group_members",
+ "tableTo": "scim_groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "scim_group_members_scim_user_id_scim_users_id_fk": {
+ "name": "scim_group_members_scim_user_id_scim_users_id_fk",
+ "tableFrom": "scim_group_members",
+ "tableTo": "scim_users",
+ "columnsFrom": [
+ "scim_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "scim_group_members_membership_key_unique": {
+ "name": "scim_group_members_membership_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "membership_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scim_groups": {
+ "name": "scim_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provisioning_domain_id": {
+ "name": "provisioning_domain_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name_key": {
+ "name": "display_name_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_id_key": {
+ "name": "external_id_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "order_key": {
+ "name": "order_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "scim_groups_connection_idx": {
+ "name": "scim_groups_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_groups_domain_idx": {
+ "name": "scim_groups_domain_idx",
+ "columns": [
+ {
+ "expression": "provisioning_domain_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "scim_groups_display_name_key_unique": {
+ "name": "scim_groups_display_name_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "display_name_key"
+ ]
+ },
+ "scim_groups_external_id_key_unique": {
+ "name": "scim_groups_external_id_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "external_id_key"
+ ]
+ },
+ "scim_groups_order_key_unique": {
+ "name": "scim_groups_order_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "order_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scim_identity_tombstones": {
+ "name": "scim_identity_tombstones",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provisioning_domain_id": {
+ "name": "provisioning_domain_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id_key": {
+ "name": "external_id_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile": {
+ "name": "profile",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "scim_identity_tombstones_connection_idx": {
+ "name": "scim_identity_tombstones_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_identity_tombstones_domain_idx": {
+ "name": "scim_identity_tombstones_domain_idx",
+ "columns": [
+ {
+ "expression": "provisioning_domain_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_identity_tombstones_user_idx": {
+ "name": "scim_identity_tombstones_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "scim_identity_tombstones_user_id_users_id_fk": {
+ "name": "scim_identity_tombstones_user_id_users_id_fk",
+ "tableFrom": "scim_identity_tombstones",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "scim_identity_tombstones_external_id_key_unique": {
+ "name": "scim_identity_tombstones_external_id_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "external_id_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scim_projection_grants": {
+ "name": "scim_projection_grants",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provisioning_domain_id": {
+ "name": "provisioning_domain_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scim_user_id": {
+ "name": "scim_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_value": {
+ "name": "source_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "grant_key": {
+ "name": "grant_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "scim_projection_grants_connection_idx": {
+ "name": "scim_projection_grants_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_projection_grants_domain_idx": {
+ "name": "scim_projection_grants_domain_idx",
+ "columns": [
+ {
+ "expression": "provisioning_domain_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_projection_grants_scim_user_idx": {
+ "name": "scim_projection_grants_scim_user_idx",
+ "columns": [
+ {
+ "expression": "scim_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_projection_grants_user_idx": {
+ "name": "scim_projection_grants_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "scim_projection_grants_scim_user_id_scim_users_id_fk": {
+ "name": "scim_projection_grants_scim_user_id_scim_users_id_fk",
+ "tableFrom": "scim_projection_grants",
+ "tableTo": "scim_users",
+ "columnsFrom": [
+ "scim_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "scim_projection_grants_user_id_users_id_fk": {
+ "name": "scim_projection_grants_user_id_users_id_fk",
+ "tableFrom": "scim_projection_grants",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "scim_projection_grants_grant_key_unique": {
+ "name": "scim_projection_grants_grant_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "grant_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scim_subjects": {
+ "name": "scim_subjects",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile_source_id": {
+ "name": "profile_source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "scim_subjects_profile_source_idx": {
+ "name": "scim_subjects_profile_source_idx",
+ "columns": [
+ {
+ "expression": "profile_source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "scim_subjects_user_id_users_id_fk": {
+ "name": "scim_subjects_user_id_users_id_fk",
+ "tableFrom": "scim_subjects",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "scim_subjects_user_id_unique": {
+ "name": "scim_subjects_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scim_users": {
+ "name": "scim_users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provisioning_domain_id": {
+ "name": "provisioning_domain_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_user_key": {
+ "name": "connection_user_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_name": {
+ "name": "user_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_name_key": {
+ "name": "user_name_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "primary_email": {
+ "name": "primary_email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "work_email_value_index": {
+ "name": "work_email_value_index",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_value_index": {
+ "name": "email_value_index",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "formatted_name": {
+ "name": "formatted_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "given_name": {
+ "name": "given_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "family_name": {
+ "name": "family_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "serialized_emails": {
+ "name": "serialized_emails",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "serialized_attributes": {
+ "name": "serialized_attributes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_id_key": {
+ "name": "external_id_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "order_key": {
+ "name": "order_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "scim_users_connection_idx": {
+ "name": "scim_users_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_users_domain_idx": {
+ "name": "scim_users_domain_idx",
+ "columns": [
+ {
+ "expression": "provisioning_domain_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "scim_users_user_idx": {
+ "name": "scim_users_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "scim_users_user_id_users_id_fk": {
+ "name": "scim_users_user_id_users_id_fk",
+ "tableFrom": "scim_users",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "scim_users_connection_user_key_unique": {
+ "name": "scim_users_connection_user_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "connection_user_key"
+ ]
+ },
+ "scim_users_user_name_key_unique": {
+ "name": "scim_users_user_name_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_name_key"
+ ]
+ },
+ "scim_users_external_id_key_unique": {
+ "name": "scim_users_external_id_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "external_id_key"
+ ]
+ },
+ "scim_users_order_key_unique": {
+ "name": "scim_users_order_key_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "order_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.bot_lifecycle": {
+ "name": "bot_lifecycle",
+ "schema": "",
+ "columns": {
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "paused_at": {
+ "name": "paused_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notify": {
+ "name": "notify",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'all'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bot_lifecycle_owner_user_id_users_id_fk": {
+ "name": "bot_lifecycle_owner_user_id_users_id_fk",
+ "tableFrom": "bot_lifecycle",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bot_lifecycle_agent_id_agents_id_fk": {
+ "name": "bot_lifecycle_agent_id_agents_id_fk",
+ "tableFrom": "bot_lifecycle",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bot_lifecycle_owner_user_id_agent_id_pk": {
+ "name": "bot_lifecycle_owner_user_id_agent_id_pk",
+ "columns": [
+ "owner_user_id",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.update_routing_preferences": {
+ "name": "update_routing_preferences",
+ "schema": "",
+ "columns": {
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "update_routing_preferences_owner_user_id_users_id_fk": {
+ "name": "update_routing_preferences_owner_user_id_users_id_fk",
+ "tableFrom": "update_routing_preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "update_routing_preferences_owner_user_id_kind_pk": {
+ "name": "update_routing_preferences_owner_user_id_kind_pk",
+ "columns": [
+ "owner_user_id",
+ "kind"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.saved_logins": {
+ "name": "saved_logins",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_password": {
+ "name": "encrypted_password",
+ "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()"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "saved_logins_owner_origin_username_idx": {
+ "name": "saved_logins_owner_origin_username_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "username",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "saved_logins_owner_idx": {
+ "name": "saved_logins_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "saved_logins_owner_user_id_users_id_fk": {
+ "name": "saved_logins_owner_user_id_users_id_fk",
+ "tableFrom": "saved_logins",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sign_in_requests": {
+ "name": "sign_in_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bot_id": {
+ "name": "bot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tool_call_id": {
+ "name": "tool_call_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "control_request_id": {
+ "name": "control_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "continuation": {
+ "name": "continuation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filling_until": {
+ "name": "filling_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "sign_in_requests_owner_idx": {
+ "name": "sign_in_requests_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sign_in_requests_owner_user_id_users_id_fk": {
+ "name": "sign_in_requests_owner_user_id_users_id_fk",
+ "tableFrom": "sign_in_requests",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "sign_in_requests_status_check": {
+ "name": "sign_in_requests_status_check",
+ "value": "\"sign_in_requests\".\"status\" IN ('pending', 'filling', 'code_required', 'taken_over', 'signed_in', 'failed', 'cancelled', 'expired')"
+ },
+ "sign_in_requests_method_check": {
+ "name": "sign_in_requests_method_check",
+ "value": "\"sign_in_requests\".\"method\" IS NULL OR \"sign_in_requests\".\"method\" IN ('typed', 'saved', 'takeover')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.team_bot_assignments": {
+ "name": "team_bot_assignments",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_name": {
+ "name": "group_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assigned_by": {
+ "name": "assigned_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assigned_at": {
+ "name": "assigned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_bot_assignments_agent_id_agents_id_fk": {
+ "name": "team_bot_assignments_agent_id_agents_id_fk",
+ "tableFrom": "team_bot_assignments",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "team_bot_assignments_assigned_by_users_id_fk": {
+ "name": "team_bot_assignments_assigned_by_users_id_fk",
+ "tableFrom": "team_bot_assignments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "assigned_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "team_bot_assignments_agent_id_group_name_pk": {
+ "name": "team_bot_assignments_agent_id_group_name_pk",
+ "columns": [
+ "agent_id",
+ "group_name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.team_bot_audience": {
+ "name": "team_bot_audience",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_bot_audience_agent_id_agents_id_fk": {
+ "name": "team_bot_audience_agent_id_agents_id_fk",
+ "tableFrom": "team_bot_audience",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "team_bot_audience_agent_id_kind_value_pk": {
+ "name": "team_bot_audience_agent_id_kind_value_pk",
+ "columns": [
+ "agent_id",
+ "kind",
+ "value"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "team_bot_audience_kind_check": {
+ "name": "team_bot_audience_kind_check",
+ "value": "\"team_bot_audience\".\"kind\" IN ('user', 'group')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.team_bot_consent_defaults": {
+ "name": "team_bot_consent_defaults",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_bot_consent_defaults_user_id_users_id_fk": {
+ "name": "team_bot_consent_defaults_user_id_users_id_fk",
+ "tableFrom": "team_bot_consent_defaults",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "team_bot_consent_defaults_user_id_server_id_pk": {
+ "name": "team_bot_consent_defaults_user_id_server_id_pk",
+ "columns": [
+ "user_id",
+ "server_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.team_bot_consents": {
+ "name": "team_bot_consents",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "decision": {
+ "name": "decision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_bot_consents_user_id_users_id_fk": {
+ "name": "team_bot_consents_user_id_users_id_fk",
+ "tableFrom": "team_bot_consents",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "team_bot_consents_agent_id_agents_id_fk": {
+ "name": "team_bot_consents_agent_id_agents_id_fk",
+ "tableFrom": "team_bot_consents",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "team_bot_consents_user_id_agent_id_server_id_pk": {
+ "name": "team_bot_consents_user_id_agent_id_server_id_pk",
+ "columns": [
+ "user_id",
+ "agent_id",
+ "server_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "team_bot_consents_decision_check": {
+ "name": "team_bot_consents_decision_check",
+ "value": "\"team_bot_consents\".\"decision\" IN ('always', 'once')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.team_bot_publications": {
+ "name": "team_bot_publications",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "published_by": {
+ "name": "published_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "audience": {
+ "name": "audience",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "team_bot_publications_agent_id_agents_id_fk": {
+ "name": "team_bot_publications_agent_id_agents_id_fk",
+ "tableFrom": "team_bot_publications",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "team_bot_publications_published_by_users_id_fk": {
+ "name": "team_bot_publications_published_by_users_id_fk",
+ "tableFrom": "team_bot_publications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "published_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "team_bot_publications_audience_check": {
+ "name": "team_bot_publications_audience_check",
+ "value": "\"team_bot_publications\".\"audience\" IN ('team', 'people')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.shared_use_approval_members": {
+ "name": "shared_use_approval_members",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "shared_use_approval_members_approval_fk": {
+ "name": "shared_use_approval_members_approval_fk",
+ "tableFrom": "shared_use_approval_members",
+ "tableTo": "shared_use_approvals",
+ "columnsFrom": [
+ "agent_id",
+ "server_id"
+ ],
+ "columnsTo": [
+ "agent_id",
+ "server_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "shared_use_approval_members_agent_id_server_id_kind_value_pk": {
+ "name": "shared_use_approval_members_agent_id_server_id_kind_value_pk",
+ "columns": [
+ "agent_id",
+ "server_id",
+ "kind",
+ "value"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "shared_use_approval_members_kind_check": {
+ "name": "shared_use_approval_members_kind_check",
+ "value": "\"shared_use_approval_members\".\"kind\" IN ('user', 'group')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.shared_use_approvals": {
+ "name": "shared_use_approvals",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "audience": {
+ "name": "audience",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "outside_input": {
+ "name": "outside_input",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "approved_by": {
+ "name": "approved_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "approved_at": {
+ "name": "approved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "shared_use_approvals_agent_id_agents_id_fk": {
+ "name": "shared_use_approvals_agent_id_agents_id_fk",
+ "tableFrom": "shared_use_approvals",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "shared_use_approvals_server_id_mcp_servers_id_fk": {
+ "name": "shared_use_approvals_server_id_mcp_servers_id_fk",
+ "tableFrom": "shared_use_approvals",
+ "tableTo": "mcp_servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "shared_use_approvals_agent_id_server_id_pk": {
+ "name": "shared_use_approvals_agent_id_server_id_pk",
+ "columns": [
+ "agent_id",
+ "server_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "shared_use_approvals_audience_check": {
+ "name": "shared_use_approvals_audience_check",
+ "value": "\"shared_use_approvals\".\"audience\" IN ('owner', 'people', 'team')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.shared_use_requests": {
+ "name": "shared_use_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "proposed_audience": {
+ "name": "proposed_audience",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "proposed_outside_input": {
+ "name": "proposed_outside_input",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "proposed_members": {
+ "name": "proposed_members",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requested_by": {
+ "name": "requested_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "decided_by": {
+ "name": "decided_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_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": {
+ "shared_use_requests_pending_idx": {
+ "name": "shared_use_requests_pending_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"shared_use_requests\".\"status\" = 'pending'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "shared_use_requests_status_idx": {
+ "name": "shared_use_requests_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "shared_use_requests_agent_id_agents_id_fk": {
+ "name": "shared_use_requests_agent_id_agents_id_fk",
+ "tableFrom": "shared_use_requests",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "shared_use_requests_server_id_mcp_servers_id_fk": {
+ "name": "shared_use_requests_server_id_mcp_servers_id_fk",
+ "tableFrom": "shared_use_requests",
+ "tableTo": "mcp_servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "shared_use_requests_audience_check": {
+ "name": "shared_use_requests_audience_check",
+ "value": "\"shared_use_requests\".\"proposed_audience\" IN ('owner', 'people', 'team')"
+ },
+ "shared_use_requests_reason_check": {
+ "name": "shared_use_requests_reason_check",
+ "value": "\"shared_use_requests\".\"reason\" IN ('publish', 'trigger', 'grant', 'refused_call')"
+ },
+ "shared_use_requests_status_check": {
+ "name": "shared_use_requests_status_check",
+ "value": "\"shared_use_requests\".\"status\" IN ('pending', 'approved', 'declined', 'superseded')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.identity_link_challenges": {
+ "name": "identity_link_challenges",
+ "schema": "",
+ "columns": {
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "identity_link_challenges_expiry_idx": {
+ "name": "identity_link_challenges_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "identity_link_challenges_user_provider_idx": {
+ "name": "identity_link_challenges_user_provider_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.identity_links": {
+ "name": "identity_links",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "realm": {
+ "name": "realm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "handle": {
+ "name": "handle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "verified_by": {
+ "name": "verified_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "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": {
+ "identity_links_identity_idx": {
+ "name": "identity_links_identity_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "realm",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "subject",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "identity_links_user_realm_idx": {
+ "name": "identity_links_user_realm_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "realm",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "identity_links_credential_idx": {
+ "name": "identity_links_credential_idx",
+ "columns": [
+ {
+ "expression": "credential_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"identity_links\".\"credential_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "identity_links_credential_id_credentials_id_fk": {
+ "name": "identity_links_credential_id_credentials_id_fk",
+ "tableFrom": "identity_links",
+ "tableTo": "credentials",
+ "columnsFrom": [
+ "credential_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "identity_links_verified_by_check": {
+ "name": "identity_links_verified_by_check",
+ "value": "\"identity_links\".\"verified_by\" IN ('challenge', 'oauth')"
+ },
+ "identity_links_status_check": {
+ "name": "identity_links_status_check",
+ "value": "\"identity_links\".\"status\" IN ('active', 'needs_reconnect')"
+ }
+ },
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.agent_type": {
+ "name": "agent_type",
+ "schema": "public",
+ "values": [
+ "built_in",
+ "remote_ag_ui",
+ "remote_mastra"
+ ]
+ },
+ "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.agent_visibility": {
+ "name": "agent_visibility",
+ "schema": "public",
+ "values": [
+ "public",
+ "private"
+ ]
+ },
+ "public.routine_run_status": {
+ "name": "routine_run_status",
+ "schema": "public",
+ "values": [
+ "succeeded",
+ "failed",
+ "skipped",
+ "waiting"
+ ]
+ },
+ "public.responsibility_run_status": {
+ "name": "responsibility_run_status",
+ "schema": "public",
+ "values": [
+ "queued",
+ "running",
+ "waiting",
+ "succeeded",
+ "failed",
+ "skipped"
+ ]
+ },
+ "public.responsibility_status": {
+ "name": "responsibility_status",
+ "schema": "public",
+ "values": [
+ "active",
+ "paused",
+ "completed"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json
index a1c2ae38a4..87ab84da4d 100644
--- a/server/drizzle/meta/_journal.json
+++ b/server/drizzle/meta/_journal.json
@@ -421,6 +421,13 @@
"when": 1792251361575,
"tag": "0059_skipped_tools",
"breakpoints": true
+ },
+ {
+ "idx": 60,
+ "version": "7",
+ "when": 1792608466985,
+ "tag": "0060_app_access_requests",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/server/src/audit.ts b/server/src/audit.ts
index 85d91e70a9..441c2f5902 100644
--- a/server/src/audit.ts
+++ b/server/src/audit.ts
@@ -268,6 +268,18 @@ export const auditEventTypes = [
"shared_use.approved",
// An administrator answered a request to use a Shared app through a Bot.
"shared_use.declined",
+ /*
+ * A person asking an administrator to switch a work app on, grant it to a Bot, or connect a
+ * Shared app's team account (`app_access_requests`), and what became of it: cancelled by the
+ * person, approved or dismissed by an administrator, or refused — a request that could not be
+ * filed, or an approval that could not be carried out. Target the app key; payload `request`,
+ * `app`, `bot`, `kind`, and for a refusal `reason`.
+ */
+ "app_access.requested",
+ "app_access.cancelled",
+ "app_access.approved",
+ "app_access.dismissed",
+ "app_access.refused",
// 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/db/schema/plugins.ts b/server/src/db/schema/plugins.ts
index 85232e3259..3c64f186f4 100644
--- a/server/src/db/schema/plugins.ts
+++ b/server/src/db/schema/plugins.ts
@@ -870,3 +870,73 @@ export const sandboxedComponents = pgTable("sandboxed_components", {
createdAt: createdAt(),
updatedAt: updatedAt(),
});
+
+/**
+ * A person asking an administrator to make one work app usable through one Bot.
+ *
+ * WHAT IS ASKED FOR IS THE SERVER'S, NEVER THE BROWSER'S. `kind` is derived from the app's state for
+ * the person and the Bot at the moment of asking — switched off is `enable`, held by no grant is
+ * `grant`, a Shared app with no team account is `connect_shared` — so a request cannot be worded as
+ * something the deployment does not actually need, and approving it does exactly the one thing
+ * that state was missing.
+ *
+ * `app_key` IS THE SERVER ID THE APP HAS OR WOULD HAVE, and is not a foreign key. A request to
+ * switch an app on names an app with no `mcp_servers` row yet — `composio-` for a Composio app,
+ * the catalogue key for one this deployment ships — and is the row's id the moment it is enabled,
+ * so one key follows the app across the change being asked for. `app_title` is the app's name as
+ * the server resolved it when the request was filed, so an administrator's list can name an app
+ * that has no row to read the name from.
+ *
+ * `agent_id` IS NULLABLE FOR A REQUEST ABOUT THE APP ALONE. Every path that files one today names
+ * the Bot, so approving a request to switch an app on can grant it to that Bot in the same step;
+ * the column admits none because "switch this on" is a complete question without one.
+ *
+ * ONE PENDING REQUEST PER (PERSON, APP, BOT, KIND), held by the partial unique index rather than by
+ * a read-then-write, so two replicas filing the same request at once cannot both insert. The Bot is
+ * read through `coalesce` because Postgres treats two NULLs as distinct, and an index on the bare
+ * column would let the same app-only request pile up.
+ *
+ * NO ROW IS DELETED WHEN IT IS ANSWERED. Approved, dismissed and cancelled requests stay, with who
+ * resolved them and when; the trail says the same thing in its own rows.
+ */
+export const appAccessRequests = pgTable(
+ "app_access_requests",
+ {
+ id: text("id").primaryKey(),
+ requestedBy: text("requested_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ agentId: text("agent_id").references(() => agents.id, {
+ onDelete: "cascade",
+ }),
+ appKey: text("app_key").notNull(),
+ appTitle: text("app_title").notNull(),
+ kind: text("kind").$type<"enable" | "grant" | "connect_shared">().notNull(),
+ status: text("status")
+ .$type<"pending" | "approved" | "dismissed" | "cancelled">()
+ .notNull()
+ .default("pending"),
+ createdAt: createdAt(),
+ resolvedAt: timestamp("resolved_at", { withTimezone: true }),
+ resolvedBy: text("resolved_by"),
+ },
+ (table) => [
+ uniqueIndex("app_access_requests_pending_idx")
+ .on(
+ table.requestedBy,
+ table.appKey,
+ sql`coalesce(${table.agentId}, '')`,
+ table.kind,
+ )
+ .where(sql`${table.status} = 'pending'`),
+ index("app_access_requests_status_idx").on(table.status, table.appKey),
+ check(
+ "app_access_requests_kind_check",
+ sql`${table.kind} IN ('enable', 'grant', 'connect_shared')`,
+ ),
+ check(
+ "app_access_requests_status_check",
+ sql`${table.status} IN ('pending', 'approved', 'dismissed', 'cancelled')`,
+ ),
+ ],
+);
diff --git a/server/src/index.ts b/server/src/index.ts
index 7f43b5ca20..77faeca575 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -180,6 +180,7 @@ import { createIntelligenceMemory } from "./memory/intelligence";
import { createMemoryStore } from "./memory/store";
import { reportModelAvailability } from "./model-availability-check";
import { memoryTools } from "./memory/tools";
+import { appAccessTools } from "./plugins/app-access-tool";
import { createSignInNotifier, createSignInResumer } from "./passwords/resume";
import { createSignInService } from "./passwords/service";
import { createPasswordStore } from "./passwords/store";
@@ -1364,6 +1365,18 @@ const loadToolsForActor =
ownerUserId: actorId,
agentId: botId,
}),
+ /*
+ * Held by every Bot, granted or not: how a Bot puts an app it cannot reach in front of the
+ * person with the step that fixes it, instead of naming an administrator. A read; see the file.
+ */
+ ...appAccessTools({
+ store: pluginStore,
+ botId,
+ actorId,
+ composioConfigured: Boolean(composio),
+ ...(composio ? { directory: () => composio.broker.listApps() } : {}),
+ sharedAudience: sharedUseStore.admitsPerson,
+ }),
...(await grantedTools({
store: pluginStore,
botId,
diff --git a/server/src/memory/ingestion.ts b/server/src/memory/ingestion.ts
index f6cc574f0a..2ad0cf932d 100644
--- a/server/src/memory/ingestion.ts
+++ b/server/src/memory/ingestion.ts
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
+import { readConnectPrompt } from "@openbot/shared/connect-prompt";
import { accessFor } from "../plugins/access";
import type { Decides } from "../plugins/broker";
import { catalogueEntry } from "../plugins/catalogue";
@@ -280,14 +281,37 @@ export function createMemoryIngestion(options: {
};
} catch (error) {
const message =
- error instanceof MemoryRefusedError ||
error instanceof PluginRefusedError
- ? error.message
- : "The source could not sync. Try again or check the connected app.";
+ ? syncRefusal(error.message)
+ : error instanceof MemoryRefusedError
+ ? error.message
+ : "The source could not sync. Try again or check the connected app.";
await store.failSync(ownerUserId, id, message);
throw error;
}
}
+ /**
+ * What the Memory sources page says about a refusal: the refusal's own sentence, except one that
+ * carries an app card.
+ *
+ * A CARD MARKER IS FOR A TRANSCRIPT, AND THIS IS NOT ONE. The marker is a JSON prefix the chat
+ * draws as a card with a Connect button; stored as the sync error it reached the sources page
+ * raw, and the sentence after it is written for a conversation ("ask again"). So a refusal with a
+ * marker is said for this page instead, naming where the account is connected.
+ */
+ function syncRefusal(text: string): string {
+ const prompt = readConnectPrompt(text);
+ if (!prompt) return text;
+ switch (prompt.reason) {
+ case "shared":
+ return `The shared ${prompt.title} account is not connected. An administrator has to connect it before this source can sync.`;
+ case "reconnect":
+ return `Your ${prompt.title} account needs reconnecting. Reconnect it under Settings, Connected accounts, then sync again.`;
+ default:
+ return `Your ${prompt.title} account is not connected. Connect it under Settings, Connected accounts, then sync again.`;
+ }
+ }
+
async function eligibleSources(ownerUserId: string, botId: string) {
const sources = (await store.sources(ownerUserId)).filter(
(source) =>
diff --git a/server/src/plugins/app-access-tool.ts b/server/src/plugins/app-access-tool.ts
new file mode 100644
index 0000000000..c9c4ec25ab
--- /dev/null
+++ b/server/src/plugins/app-access-tool.ts
@@ -0,0 +1,119 @@
+import {
+ connectAppRefusal,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
+import type { GrantedTool } from "@openbot/toolkit/granted-tool";
+import { z } from "zod";
+import type { AppAccess, AppAccessState } from "./app-access";
+import type {
+ AppDirectory,
+ SharedAudienceCheck,
+} from "./plugin-store/app-access";
+import type { PluginStore } from "./store";
+
+/**
+ * The tool every Bot holds for putting a work app's state in front of the person asking.
+ *
+ * WHY A TOOL AND NOT A SENTENCE. A Bot that met an app it does not hold used to be told to "say an
+ * administrator can grant it", and did — correctly, and uselessly: the person was handed the name of
+ * a role and no way to reach it, about an app the Bot could have had all along. The other ways an
+ * app is out of reach already drew a card, because each of them is a refused call and the refusal
+ * carries one (`@openbot/shared/connect-prompt`). An app the Bot holds nothing for is never called,
+ * so nothing was refused and there was nothing to draw. This is the call.
+ *
+ * THE ANSWER IS THE SAME MARKER A REFUSAL CARRIES, with `reason: "access"`, so the transcript draws
+ * it with the same card, and the card asks the server for the app's state for this person and this
+ * Bot when it is drawn — never trusting what this answer said, which is only what was true when the
+ * model asked. The sentence after the marker is what the model reads: the state in words, and what
+ * the card offers, so it does not go on to repeat the card at length.
+ *
+ * A READ, AND ONLY EVER ONE. Nothing here enables, grants or files anything — pressing Request
+ * access on the card does that, as the person, through the same route the Bot page uses, where the
+ * server decides what is asked for. So the tool is safe to hand every Bot: the worst a model can do
+ * with it is show somebody an accurate card about an app they did not ask about.
+ */
+export function appAccessTools(options: {
+ store: Pick;
+ botId: string;
+ actorId: string;
+ composioConfigured: boolean;
+ /** Names an app this deployment has no row for. Absent without the broker's key. */
+ directory?: AppDirectory;
+ /** The shared-use gate's answer for this person, so a Shared app it would refuse says so. */
+ sharedAudience?: SharedAudienceCheck;
+}): GrantedTool[] {
+ const parameters = z
+ .object({
+ app: z
+ .string()
+ .trim()
+ .min(1)
+ .max(200)
+ .describe(
+ "The app's name (Gmail, Slack, Google Drive) or its id as your instructions list it.",
+ ),
+ })
+ .strict();
+ return [
+ {
+ name: SHOW_APP_ACCESS_TOOL,
+ ref: `app-access/${SHOW_APP_ACCESS_TOOL}`,
+ description:
+ "Show the person a card saying whether they can use one work app through you, and the one thing that would let them: connecting their own account, or asking an administrator to switch the app on, grant it to you, or connect its shared account. Call it whenever a question needs an app you hold no tools for, instead of only saying it is missing.",
+ parameters,
+ async execute(args) {
+ const parsed = parameters.safeParse(args);
+ if (!parsed.success) return "Name the app to show.";
+ const { app } = parsed.data;
+ const inspected = await options.store.inspectApp({
+ app,
+ agentId: options.botId,
+ userId: options.actorId || null,
+ composioConfigured: options.composioConfigured,
+ ...(options.directory ? { directory: options.directory } : {}),
+ ...(options.sharedAudience
+ ? { sharedAudience: options.sharedAudience }
+ : {}),
+ });
+ /*
+ * No card for an app nobody can name. A card about it would have nothing to ask the server
+ * and nothing to offer, and the model is better told plainly so it can say so.
+ */
+ if (!inspected)
+ return `This deployment has no app called ${app}, so there is nothing to show. Say so plainly; do not browse to it instead.`;
+ const { access } = inspected;
+ return connectAppRefusal({
+ serverId: access.key,
+ title: access.displayName,
+ agentId: options.botId,
+ reason: "access",
+ message: toldTheModel(access),
+ });
+ },
+ },
+ ];
+}
+
+/**
+ * What the model reads about the card it just put on screen: the state in words, and that the card
+ * already offers the next step, so the reply around it is a sentence rather than a set of
+ * instructions the person can already see.
+ */
+function toldTheModel(access: AppAccess): string {
+ const name = access.displayName;
+ const owners = access.accountHolder === "owner";
+ const said: Record = {
+ ready: access.asksFirst
+ ? `${name} is ready for this person to use through you, on their own account; the first call will ask them to allow that. Use its tools rather than the card.`
+ : `${name} is ready for this person to use through you. Use its tools rather than the card.`,
+ not_connected: `${name} is granted to you, and this person has not connected their own account. The card in front of them offers Connect; ask them to try again once they have.`,
+ reconnect: owners
+ ? `${name} is granted to you and runs on your owner's account, whose connection needs reconnecting. Only your owner can reconnect it; the card in front of the person says so.`
+ : `${name} is granted to you, and this person's connection needs reconnecting. The card in front of them offers Reconnect; ask them to try again once they have.`,
+ shared_not_connected: `${name} uses one shared account for this deployment, and an administrator has not connected it yet. The card in front of the person lets them ask an administrator for it.`,
+ shared_not_approved: `${name} uses one shared account for this deployment, and you reach more people than an administrator approved for it, so its calls are refused. Only an administrator can approve that; the card in front of the person says so.`,
+ disabled: `${name} is switched off on this deployment. The card in front of the person lets them ask an administrator to switch it on.`,
+ not_granted: `${name} is switched on, and you have not been granted it. The card in front of the person lets them ask an administrator to grant it to you.`,
+ };
+ return `${said[access.state]} Do not browse to its website or ask them to sign in there.`;
+}
diff --git a/server/src/plugins/app-access.ts b/server/src/plugins/app-access.ts
new file mode 100644
index 0000000000..749fe5a826
--- /dev/null
+++ b/server/src/plugins/app-access.ts
@@ -0,0 +1,268 @@
+import { type BrokerProvider, brokeredProvenance } from "./access";
+import { schemeKind } from "./broker";
+import type { CatalogueEntry } from "./catalogue";
+
+/**
+ * Whether one person can use one work app through one Bot, and if not, what the next step is and
+ * whose it is.
+ *
+ * WHY THIS EXISTS AS ONE ANSWER. Every fact it reads already had a home: whether the app is
+ * enabled is a row in `mcp_servers`, whether the broker is configured is a key in the environment,
+ * whether the Bot holds the app is `plugin_grants`, whether the person connected is
+ * `brokered_connections` or `mcp_user_credentials`, and whether a Shared app has its team account
+ * is a deployment row in `brokered_connections`. Each screen that wanted to say "you can use this"
+ * re-derived it from whichever of those it had loaded, and the Bot page had none of the
+ * connection facts at all — so it listed what a Bot holds with no word about whether the person
+ * looking could use any of it. Resolved here, once, from the same tables every gate reads, and sent
+ * to the browser as a word it draws rather than a rule it recomputes.
+ *
+ * NOTHING HERE ASKS A VENDOR. "Connected" is what this deployment last recorded — the `verified`
+ * and `probe` columns a connect or a re-check wrote, the `revoked_at` a withdrawal set — exactly
+ * as `GET /api/plugins/connections` reports it. A connection that died at the vendor since then
+ * reads as `ready` until the next call or re-check finds out; asking the vendor on every page load
+ * would be a network call per app per person per render, which is the cost those columns exist to
+ * avoid.
+ */
+
+/**
+ * The seven things a person can be told about one app on one Bot.
+ *
+ * `ready` — usable now, as far as this deployment knows.
+ * `not_connected` — enabled and held, and this app is used under each person's own account, which
+ * this person has not connected. Theirs to fix.
+ * `reconnect` — they connected once, and the connection is withdrawn or its key was refused. Theirs
+ * to fix.
+ * `shared_not_connected` — a Shared app whose one team account is not connected, or whose key was
+ * refused. Only an administrator can connect it.
+ * `shared_not_approved` — a Shared app whose team account is connected, on a Bot that reaches more
+ * people than an administrator approved for that account. Every call is refused by the shared-use
+ * gate, which files the administrator's request itself; nothing is left for a press to ask.
+ * `disabled` — switched off at the deployment: no row, the broker not configured, or an app that
+ * still needs something only an administrator can supply.
+ * `not_granted` — enabled, and this Bot holds none of its tools. Only an administrator can grant.
+ */
+export const APP_ACCESS_STATES = [
+ "ready",
+ "not_connected",
+ "reconnect",
+ "shared_not_connected",
+ "shared_not_approved",
+ "disabled",
+ "not_granted",
+] as const;
+
+export type AppAccessState = (typeof APP_ACCESS_STATES)[number];
+
+/** What an administrator would be asked to do. See `app_access_requests`. */
+export type AppAccessRequestKind = "enable" | "grant" | "connect_shared";
+
+/**
+ * Which request each state files, and null where the next step is the person's own.
+ *
+ * A TABLE RATHER THAN A BRANCH, so the compiler asks the question again for any state added later.
+ * The browser never names a kind: it asks for access, and the server files the kind this table
+ * names for the state it computed.
+ */
+export const REQUEST_KIND_FOR: Record<
+ AppAccessState,
+ AppAccessRequestKind | null
+> = {
+ ready: null,
+ not_connected: null,
+ reconnect: null,
+ shared_not_connected: "connect_shared",
+ shared_not_approved: null,
+ disabled: "enable",
+ not_granted: "grant",
+};
+
+/** Whose account the app's calls land in. */
+export type AppAccount =
+ /** Each person's own, which each person connects. */
+ | "personal"
+ /** One team account the deployment holds, which only an administrator connects. */
+ | "shared"
+ /** No person's account: a deployment token, a server added by URL, or no credential at all. */
+ | "deployment";
+
+/**
+ * How the account is connected, for the screen that starts it: the deployment's own OAuth flow, a
+ * broker's consent screen, a broker's key form, or nothing to connect.
+ */
+export type AppConnect = "oauth" | "consent" | "key" | null;
+
+/**
+ * Where the app comes from, which decides what enabling it takes.
+ *
+ * A BROKERED APP IS NAMED BY ITS BROKER, `composio` or `arcade`, and every rule below asks
+ * {@link brokeredProvenance} rather than comparing to one of them, so the two are read alike.
+ */
+export type AppProvider = BrokerProvider | "catalogue" | "custom";
+
+/** One app as the Bot page and the chat card draw it. */
+export type AppAccess = {
+ /** The server id the app has, or the one enabling it would mint. What a request names. */
+ key: string;
+ /**
+ * The app's own name — "Gmail", never "Composio Gmail" and never a humanised `composio-gmail`.
+ * A brokered row's title is the directory's name for the app; a catalogue row's is the entry's.
+ */
+ displayName: string;
+ logo: string | null;
+ provider: AppProvider;
+ account: AppAccount;
+ connect: AppConnect;
+ /** The row's recorded scheme, for a brokered app; null otherwise. */
+ authScheme: string | null;
+ /** The app's mode, off its answering row; null for an app that is not brokered. */
+ accountMode: "personal" | "shared" | null;
+ state: AppAccessState;
+ /** Why nobody can use the app, when `state` is `disabled`; null otherwise. See {@link Unavailable}. */
+ unavailable: Unavailable | null;
+ /**
+ * Whose own account a personal app's calls go out on for this person: theirs, or — on somebody
+ * else's published Team Bot whose owner connected the app — the owner's, which only the owner can
+ * reconnect. Null where the app is not used under a person's own account.
+ *
+ * THE SAME ANSWER `credentialActorFor` GIVES A CALL, read without its side effects: the owner's
+ * account wherever the owner has one, and otherwise the person's own.
+ */
+ accountHolder: "you" | "owner" | null;
+ /**
+ * Whether the first call on the person's own account through somebody else's Team Bot will stop
+ * to ask them to allow it, because they have given that Bot no standing consent. The state is
+ * still `ready`: the question is asked in the conversation, and answering it is the next step.
+ */
+ asksFirst: boolean;
+ /** Whether this Bot holds any of the app's tools. */
+ granted: boolean;
+ /**
+ * When the account this app runs in for this person was connected — their own, or the team's for
+ * a Shared app — and null where none is, or where no account is involved.
+ *
+ * FOR THE ONE FACT THE STATE CANNOT ALWAYS CARRY. The call that finds an account lost at the
+ * vendor now deletes its row, so the state says so itself from then on; a refusal written before
+ * that, over a row that still stands, reads `ready` here and its card says Reconnect anyway. This
+ * is how that card tells "lost since the refusal" from "connected again since the refusal": a
+ * connection newer than the refusal is the reconnection, and the live state wins.
+ */
+ connectedAt: string | null;
+ /** The request a press of Request access files for this state, and null where there is none. */
+ requestKind: AppAccessRequestKind | null;
+ /**
+ * This person's pending request for exactly that kind, if they already asked. A pending request
+ * of another kind is left out: it asks for something this state no longer needs.
+ */
+ request: { id: string; kind: AppAccessRequestKind; createdAt: string } | null;
+};
+
+/**
+ * Everything the state is decided from, gathered by the store. Each field is a fact some table
+ * already holds; nothing here is a judgement.
+ */
+export type AppAccessFacts = {
+ provider: AppProvider;
+ /** Whether a row stands for the app at all. */
+ present: boolean;
+ /** Whether this deployment has its broker's key, for a brokered app. */
+ composioConfigured: boolean;
+ /** The scheme the app's answering row records, for a brokered app. */
+ authScheme: string | null;
+ accountMode: "personal" | "shared" | null;
+ /** The catalogue entry's auth, for an app this deployment ships. */
+ catalogueAuth: CatalogueEntry["auth"] | null;
+ /** Whether the row holds a credential: the OAuth client, or the deployment's token. */
+ hasCredential: boolean;
+ granted: boolean;
+ /** This person's own connection, or null where they have none. */
+ personal: { usable: boolean } | null;
+ /** The deployment's account at a Shared app, or null where none is connected. */
+ shared: { usable: boolean } | null;
+ /**
+ * Whether the shared-use gate would let this person's calls through this Bot reach the team
+ * account, or null where nobody asked — no person named, or not a Shared app.
+ */
+ sharedAdmitted: boolean | null;
+};
+
+/** Whose account, from the same facts `accessFor` resolves a call with. */
+export function accountOf(facts: AppAccessFacts): AppAccount {
+ if (brokeredProvenance(facts.provider)) {
+ if (schemeKind(facts.authScheme) === "none") return "deployment";
+ return facts.accountMode === "shared" ? "shared" : "personal";
+ }
+ if (facts.provider === "catalogue")
+ return facts.catalogueAuth?.kind === "user-oauth"
+ ? "personal"
+ : "deployment";
+ return "deployment";
+}
+
+export function connectOf(facts: AppAccessFacts): AppConnect {
+ if (brokeredProvenance(facts.provider)) {
+ const kind = schemeKind(facts.authScheme);
+ return kind === "consent" || kind === "key" ? kind : null;
+ }
+ return facts.catalogueAuth?.kind === "user-oauth" ? "oauth" : null;
+}
+
+/**
+ * Whether the deployment has what it needs for anybody to use the app, before any grant or
+ * connection is asked about; null when it does, and the reason when it does not.
+ *
+ * THE REASONS ARE THE ONES APPROVING AN `enable` REQUEST HAS TO ANSWER, so the approve route reads
+ * this too and refuses with the step that is missing rather than recording an approval nothing
+ * changed.
+ */
+export type Unavailable =
+ | "absent"
+ | "composio_unconfigured"
+ | "scheme_unreadable"
+ | "oauth_client_missing"
+ | "token_missing";
+
+export function unavailableReason(facts: AppAccessFacts): Unavailable | null {
+ if (!facts.present) return "absent";
+ if (brokeredProvenance(facts.provider)) {
+ if (!facts.composioConfigured) return "composio_unconfigured";
+ if (schemeKind(facts.authScheme) === "unreadable")
+ return "scheme_unreadable";
+ return null;
+ }
+ const auth = facts.catalogueAuth;
+ if (auth?.kind === "user-oauth") {
+ /*
+ * A client that registers itself on first use is ready the moment the row exists; a manual
+ * one waits for an administrator to paste it in, and until then nobody can connect — the
+ * connect route answers that with the same sentence for everybody.
+ */
+ if (auth.clientRegistration !== "dynamic" && !facts.hasCredential)
+ return "oauth_client_missing";
+ return null;
+ }
+ if (auth?.kind === "deployment-bearer" && !facts.hasCredential)
+ return "token_missing";
+ return null;
+}
+
+/**
+ * The rule, in the order a person meets the obstacles: the deployment first, then the Bot, then
+ * the account — and, for a team account, whether this Bot's audience was approved for it.
+ *
+ * A KEY THE VENDOR REFUSED READS AS `reconnect`, AND AN UNCHECKED ONE AS `ready`. The store
+ * reduces each connection to `usable` the way `brokeredConnectionsFor` sets out its four states: a
+ * probe that ran and was refused is a dead key; no probe at all is a key accepted without being
+ * tried, which is exactly what the settings page says about it too.
+ */
+export function appAccessState(facts: AppAccessFacts): AppAccessState {
+ if (unavailableReason(facts) !== null) return "disabled";
+ if (!facts.granted) return "not_granted";
+ const account = accountOf(facts);
+ if (account === "deployment") return "ready";
+ if (account === "shared") {
+ if (!facts.shared?.usable) return "shared_not_connected";
+ return facts.sharedAdmitted === false ? "shared_not_approved" : "ready";
+ }
+ if (!facts.personal) return "not_connected";
+ return facts.personal.usable ? "ready" : "reconnect";
+}
diff --git a/server/src/plugins/broker.ts b/server/src/plugins/broker.ts
index 33fb0b6c8d..caa69f7e49 100644
--- a/server/src/plugins/broker.ts
+++ b/server/src/plugins/broker.ts
@@ -910,6 +910,26 @@ export class BrokerUnconfiguredError extends BrokerRefusalError {
}
}
+/**
+ * An account this deployment still has a row for and the vendor no longer holds.
+ *
+ * ITS OWN CLASS BECAUSE IT IS THE ONE VENDOR CONDITION A PERSON CAN FIX FROM THE CONVERSATION. A
+ * grant withdrawn at the provider and an account removed in Composio's dashboard both arrive like
+ * this, nothing on this deployment records either — the `brokered_connections` row still stands and
+ * the app still reads as ready — and connecting again is the whole remedy. Telling it apart by type
+ * is what lets the tool call that met it hand the transcript a card with Reconnect on it instead of
+ * a sentence naming a settings page, without anything matching on the sentence.
+ *
+ * A {@link BrokerRefusalError} because it keeps that class's promise about the message, and because
+ * every reader that already prefers an authored sentence goes on preferring this one.
+ */
+export class BrokerAccountGoneError extends BrokerRefusalError {
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "BrokerAccountGoneError";
+ }
+}
+
/**
* A return address that could not bring anybody back, refused before a consent is spent on it.
*
diff --git a/server/src/plugins/composio-vendor.ts b/server/src/plugins/composio-vendor.ts
index 9e29f74fde..76cb5b0e12 100644
--- a/server/src/plugins/composio-vendor.ts
+++ b/server/src/plugins/composio-vendor.ts
@@ -1,4 +1,5 @@
import {
+ BrokerAccountGoneError,
type BrokerApp,
type BrokerConnection,
BrokerRefusalError,
@@ -1752,8 +1753,13 @@ function vendorRefusal(
* neither; connecting again is what puts an account back under the row.
*/
case "ComposioConnectedAccountNotFoundError":
- return refusal(
+ /*
+ * Raised as its own class so the tool call that met it can put Reconnect in front of the
+ * person rather than this sentence alone. See {@link BrokerAccountGoneError}.
+ */
+ return new BrokerAccountGoneError(
`Composio holds no connected account for this person and ${app}, so ${outcome}. A grant withdrawn at the provider and an account removed in Composio's own dashboard both read exactly like this, and a retry reaches neither: connecting ${app} again on this deployment's Connected accounts page is what restores it.`,
+ { cause: error },
);
/*
diff --git a/server/src/plugins/composio.ts b/server/src/plugins/composio.ts
index 240a926ffd..7a8dc07271 100644
--- a/server/src/plugins/composio.ts
+++ b/server/src/plugins/composio.ts
@@ -1,5 +1,5 @@
import { cutAtCodeUnits } from "../channels/text";
-import { brokerSentence, flagOf } from "./broker";
+import { BrokerAccountGoneError, brokerSentence, flagOf } from "./broker";
import { type ListedTool, MAX_RESULT_CHARS, type McpCallResult } from "./mcp";
/**
@@ -1767,6 +1767,18 @@ export async function askAction(
* vendor's own words below, exactly as before.
*/
const authored = brokerSentence(error);
+ /*
+ * THE ONE AUTHORED CONDITION THAT TRAVELS AS A FACT AS WELL AS A SENTENCE. An account the
+ * vendor no longer holds is something the person can fix themselves, from the conversation,
+ * and nothing on this deployment's rows knows it happened — so the result says so in a field
+ * the store reads, rather than leaving it to be recognised from the wording. See
+ * {@link BrokerAccountGoneError} and `McpCallResult.accountGone`.
+ */
+ if (authored !== null && error instanceof BrokerAccountGoneError)
+ return {
+ result: { ...failure(authored), accountGone: true },
+ answered: false,
+ };
if (authored !== null) return unreached(authored);
/*
diff --git a/server/src/plugins/mcp.ts b/server/src/plugins/mcp.ts
index b2265c1ca6..5134c8e60d 100644
--- a/server/src/plugins/mcp.ts
+++ b/server/src/plugins/mcp.ts
@@ -446,6 +446,12 @@ export type McpCallResult = {
/** True when the server itself reported the call as an error rather than failing to answer. */
isError: boolean;
truncated: boolean;
+ /**
+ * The vendor no longer holds the account this call was made in, though this deployment still has
+ * a row for it. Only the Composio transport sets it, and only on an `isError` result; the store
+ * turns it into a refusal that offers Reconnect. Absent everywhere else.
+ */
+ accountGone?: true;
};
/**
diff --git a/server/src/plugins/plugin-store/app-access.ts b/server/src/plugins/plugin-store/app-access.ts
new file mode 100644
index 0000000000..3ab22b6005
--- /dev/null
+++ b/server/src/plugins/plugin-store/app-access.ts
@@ -0,0 +1,1091 @@
+import { randomUUID } from "node:crypto";
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ inArray,
+ isNull,
+ or,
+ type SQL,
+} from "drizzle-orm";
+import { type AuditEventInput, recordAuditEvent } from "../../audit";
+import {
+ agentProfiles,
+ agents,
+ appAccessRequests,
+ brokeredConnections,
+ credentials as credentialRows,
+ mcpServers,
+ mcpTools,
+ mcpUserCredentials,
+ pluginGrants,
+ teamBotConsentDefaults,
+ teamBotConsents,
+ teamBotPublications,
+ users,
+} from "../../db/schema";
+import { BROKERED_PROFILE, brokeredProvenance, toolkitOfFor } from "../access";
+import {
+ type AppAccess,
+ type AppAccessFacts,
+ type AppAccessRequestKind,
+ type AppProvider,
+ accountOf,
+ appAccessState,
+ connectOf,
+ REQUEST_KIND_FOR,
+ type Unavailable,
+ unavailableReason,
+} from "../app-access";
+import type { BrokerApp } from "../broker";
+import { CATALOGUE, type CatalogueEntry, catalogueEntry } from "../catalogue";
+import type { StoreContext } from "./context";
+import { iso } from "./model";
+
+/**
+ * One app as this module resolves it before anybody's connection is asked about: which rows are
+ * it, which of them answers for it, and what the deployment holds for it.
+ *
+ * `rowIds` IS EVERY ROW THAT IS THIS APP, so a grant made through a duplicate row at a brokered
+ * app's url counts as holding the app — the same "the apps, not the rows" rule
+ * `sharedAppsHeldBy` follows. `answeringId` is the one row whose columns decide, and the one a
+ * grant made by approving a request is written against.
+ */
+type AppCandidate = {
+ key: string;
+ title: string;
+ logo: string | null;
+ provider: AppProvider;
+ present: boolean;
+ answeringId: string | null;
+ rowIds: string[];
+ toolkit: string | null;
+ entry: CatalogueEntry | null;
+ authScheme: string | null;
+ accountMode: "personal" | "shared" | null;
+ hasCredential: boolean;
+ /**
+ * Whether anything but the caller's own word says this app exists: a row, a catalogue entry, or
+ * the vendor's directory. False only for an id in the broker's namespace (`composio-`,
+ * `arcade-`) the directory did not list — named so a grant that outlived its row still reads,
+ * and never something a request may be filed to enable.
+ */
+ vouched: boolean;
+};
+
+type ServerRow = {
+ id: string;
+ title: string;
+ logo: string | null;
+ url: string;
+ provenance: string;
+ authScheme: string | null;
+ accountMode: "personal" | "shared" | null;
+ credentialId: string | null;
+};
+
+/** The directory, asked only to name an app this deployment has no row for. */
+export type AppDirectory = () => Promise;
+
+/** What one app is for the deployment and, where a person was named, for them. */
+export type AppInspection = {
+ access: AppAccess;
+ /** Why nobody can use it yet, as {@link unavailableReason} answers; null when somebody can. */
+ unavailable: Unavailable | null;
+ /** The row a grant is written against, and null for an app with no row. */
+ answeringId: string | null;
+ /** The app at the broker, for a brokered one. */
+ toolkit: string | null;
+ /** The catalogue entry, for an app this deployment ships. */
+ entry: CatalogueEntry | null;
+ /** Whether the Shared team account is connected and its key was not refused. */
+ sharedUsable: boolean;
+ /** See {@link AppCandidate.vouched}. */
+ vouched: boolean;
+};
+
+/**
+ * Whether the shared-use gate would admit this person's calls through this Bot to a Shared app's
+ * team account — the gate's own rule, without the request it files when it refuses.
+ */
+export type SharedAudienceCheck = (input: {
+ botId: string;
+ serverId: string;
+ actorId: string;
+}) => Promise;
+
+export type AppAccessRequestStatus =
+ | "pending"
+ | "approved"
+ | "dismissed"
+ | "cancelled";
+
+/** A request as the person who filed it and the administrator answering it both read it. */
+export type AppAccessRequestRecord = {
+ id: string;
+ appKey: string;
+ appTitle: string;
+ kind: AppAccessRequestKind;
+ status: AppAccessRequestStatus;
+ agentId: string | null;
+ /** The Bot's name, or null for a request about the app alone. */
+ botName: string | null;
+ /** Who asked. Shown to an administrator because they asked; nothing about their connections is. */
+ requestedBy: string;
+ requesterEmail: string | null;
+ requesterName: string | null;
+ createdAt: string;
+ resolvedAt: string | null;
+ resolvedBy: string | null;
+};
+
+/**
+ * Whether `error` is a Postgres unique_violation (SQLSTATE 23505). Bun's driver puts the SQLSTATE in
+ * `errno`, and drizzle wraps the driver error as `cause` — the same reading `identity/store.ts` makes.
+ */
+function isUniqueViolation(error: unknown): boolean {
+ type Driver = { errno?: unknown };
+ return [
+ error as Driver | null | undefined,
+ (error as { cause?: Driver } | null | undefined)?.cause,
+ ].some((candidate) => candidate?.errno === "23505");
+}
+
+/**
+ * Per-person, per-Bot status of the work apps a Bot holds, and the requests a person files when
+ * only an administrator can change it. See `../app-access.ts` for the rule itself.
+ *
+ * ONE BROKER, THE STORE'S. Which rows are brokered apps, which connections are theirs, the scheme
+ * an app's url is minted at and the prefix its row id takes all come from `brokerProvider` and
+ * {@link BROKERED_PROFILE}, so a personal Arcade app is read exactly as a personal Composio app is
+ * — and a row left by the broker this deployment does not run is no brokered app here, as it is
+ * no brokered app to any other reader in the store.
+ */
+export function appAccessMethods(context: StoreContext) {
+ const { database, auditStore, brokeredAppRowsAt, brokerProvider } = context;
+ const { scheme, idPrefix } = BROKERED_PROFILE[brokerProvider];
+ /** Whether an id is in the namespace `addBrokeredApp` mints this broker's rows into. */
+ const isBrokeredId = (id: string) => id.startsWith(idPrefix);
+ /** An id with that namespace taken off, and any other word as it came. */
+ const slugOfId = (id: string) =>
+ isBrokeredId(id) ? id.slice(idPrefix.length) : id;
+
+ /*
+ * EVERY ROW, ONCE. `mcp_servers` is the deployment's list of apps — tens of rows, not thousands —
+ * and every question below is about which of them are one app. Reading the table once and
+ * grouping it here costs one query; asking per app would cost one per app and per duplicate.
+ * Ordered by id so the grouping meets rows in the order the answering rule names them.
+ */
+ async function allRows(): Promise {
+ return database
+ .select({
+ id: mcpServers.id,
+ title: mcpServers.title,
+ logo: mcpServers.logo,
+ url: mcpServers.url,
+ provenance: mcpServers.provenance,
+ authScheme: mcpServers.authScheme,
+ accountMode: mcpServers.accountMode,
+ credentialId: mcpServers.credentialId,
+ })
+ .from(mcpServers)
+ .orderBy(asc(mcpServers.id));
+ }
+
+ /**
+ * The rows grouped into apps, keyed by every row id, so a grant's server half finds its app.
+ *
+ * A BROKERED APP IS ITS ANSWERING ROW, whichever row a grant names: the id, title, scheme and
+ * mode all come off the row {@link brokeredAppRowsAt} names for the app's canonical url — the one
+ * rule every brokered reader in the store answers by. A brokered row with no row at the
+ * canonical url is an app of its own whose scheme nobody recorded, which the state reads as
+ * switched off; the gate refuses such a row too.
+ */
+ async function appsByRow(rows: ServerRow[]) {
+ const toolkitOfRow = new Map();
+ for (const row of rows) {
+ const toolkit =
+ row.provenance === brokerProvider
+ ? toolkitOfFor(brokerProvider, row.url)
+ : null;
+ if (toolkit && !catalogueEntry(row.id)) toolkitOfRow.set(row.id, toolkit);
+ }
+ const answering = await brokeredAppRowsAt([
+ ...new Set([...toolkitOfRow.values()].map((t) => `${scheme}${t}`)),
+ ]);
+ const byId = new Map(rows.map((row) => [row.id, row]));
+ const apps = new Map();
+ const byRow = new Map();
+
+ for (const row of rows) {
+ const toolkit = toolkitOfRow.get(row.id) ?? null;
+ const head = toolkit ? answering.get(`${scheme}${toolkit}`) : undefined;
+ const key = head?.id ?? row.id;
+ let app = apps.get(key);
+ if (!app) {
+ const source = byId.get(key) ?? row;
+ const entry = toolkit ? null : catalogueEntry(row.id);
+ app = {
+ key,
+ title: entry?.title ?? source.title,
+ logo: source.logo,
+ provider: toolkit ? brokerProvider : entry ? "catalogue" : "custom",
+ present: true,
+ answeringId: key,
+ rowIds: [],
+ toolkit,
+ entry,
+ authScheme: toolkit ? (head?.authScheme ?? null) : source.authScheme,
+ accountMode: toolkit ? (head?.accountMode ?? null) : null,
+ hasCredential: source.credentialId !== null,
+ vouched: true,
+ };
+ apps.set(key, app);
+ }
+ app.rowIds.push(row.id);
+ byRow.set(row.id, app);
+ }
+ return byRow;
+ }
+
+ /**
+ * An app with no row: a catalogue entry nobody added, or a brokered app nobody enabled.
+ *
+ * `key` is the id enabling it would mint — the entry's key, or the broker's prefix and the slug
+ * (`composio-`, `arcade-`) as `addBrokeredApp` names a row — so a request filed
+ * now names the row the approval creates.
+ */
+ function absent(input: {
+ key: string;
+ title: string;
+ logo?: string | null;
+ provider: AppProvider;
+ toolkit?: string | null;
+ entry?: CatalogueEntry | null;
+ vouched?: boolean;
+ }): AppCandidate {
+ return {
+ key: input.key,
+ title: input.title,
+ logo: input.logo ?? null,
+ provider: input.provider,
+ present: false,
+ answeringId: null,
+ rowIds: [],
+ toolkit: input.toolkit ?? null,
+ entry: input.entry ?? null,
+ authScheme: null,
+ accountMode: null,
+ hasCredential: false,
+ vouched: input.vouched ?? true,
+ };
+ }
+
+ /*
+ * THE DIRECTORY IS ASKED ONLY FOR AN APP THIS DEPLOYMENT HAS NO ROW FOR, and its failure is an
+ * absence rather than an error: it is the one read here that leaves this process, it is cached by
+ * the adapter, and a vendor that will not answer leaves an app unnamed rather than a page that
+ * cannot load.
+ */
+ async function readDirectory(directory: AppDirectory | undefined) {
+ if (!directory) return null;
+ try {
+ return await directory();
+ } catch {
+ return null;
+ }
+ }
+
+ /** A key with no row, named as well as the deployment can name it. */
+ async function absentFor(
+ key: string,
+ directory: AppDirectory | undefined,
+ listed: BrokerApp[] | null | undefined,
+ ): Promise {
+ const entry = catalogueEntry(key);
+ if (entry)
+ return absent({
+ key: entry.key,
+ title: entry.title,
+ provider: "catalogue",
+ entry,
+ });
+ const slug = slugOfId(key);
+ if (toolkitOfFor(brokerProvider, `${scheme}${slug}`) !== slug) return null;
+ const apps = listed === undefined ? await readDirectory(directory) : listed;
+ const app = apps?.find((candidate) => candidate.slug === slug);
+ if (app)
+ return absent({
+ key: `${idPrefix}${app.slug}`,
+ title: app.name,
+ logo: app.logo,
+ provider: brokerProvider,
+ toolkit: app.slug,
+ });
+ /*
+ * An id in the broker's namespace that the directory cannot vouch for is still an app somebody
+ * once enabled — a grant can outlive its row — so it is named by its slug rather than dropped.
+ * A bare word that matches nothing is not an app at all.
+ */
+ if (isBrokeredId(key))
+ return absent({
+ key,
+ title: slug,
+ provider: brokerProvider,
+ toolkit: slug,
+ vouched: false,
+ });
+ return null;
+ }
+
+ /**
+ * One app by whatever a caller called it: a server id, a catalogue key, a brokered app's slug
+ * with or without its id prefix (`composio-`, `arcade-`), or the app's name. Null for a word that
+ * names no app here or in the directory.
+ *
+ * A SLUG IS MATCHED AS TYPED FIRST, THEN LOWERCASED. Arcade's toolkit names are case-sensitive
+ * (`Gmail`), so the word as the caller typed it is the one that can reach them; Composio's slugs
+ * are lowercase, so the lowercased word is the one that always reached those, and still does.
+ *
+ * A NAME FINDS WHAT IS ENABLED BEFORE WHAT COULD BE. "Google Drive" is the title of the built-in
+ * `google-drive` entry and of a brokered row alike; on a deployment that enabled the brokered one
+ * and never added the built-in, the name is about the app that is there, and answering with the
+ * entry instead would call it switched off and file a request to enable something nobody needs.
+ * So an exact id or key is taken as it was given, and a bare name is matched against the rows
+ * first — the one this Bot holds, where two rows share it — and only then against the catalogue.
+ */
+ async function candidateFor(
+ app: string,
+ directory: AppDirectory | undefined,
+ granted: Set,
+ ): Promise {
+ const asked = app.trim();
+ if (!asked) return null;
+ const lower = asked.toLowerCase();
+ const rows = await allRows();
+ const byRow = await appsByRow(rows);
+ const enabled = [...new Set(byRow.values())];
+
+ const byId = byRow.get(asked);
+ if (byId) return byId;
+
+ const typed = slugOfId(asked);
+ const slug = slugOfId(lower);
+ for (const wanted of new Set([typed, slug]))
+ for (const candidate of enabled)
+ if (candidate.toolkit === wanted) return candidate;
+
+ const keyed = catalogueEntry(lower);
+ if (keyed)
+ return byRow.get(keyed.key) ?? absentFor(keyed.key, undefined, null);
+
+ const titled = enabled.filter(
+ (candidate) => candidate.title.toLowerCase() === lower,
+ );
+ const held = titled.find((candidate) =>
+ [candidate.key, ...candidate.rowIds].some((id) => granted.has(id)),
+ );
+ if (held ?? titled[0]) return held ?? titled[0] ?? null;
+
+ const entry = CATALOGUE.find((item) => item.title.toLowerCase() === lower);
+ if (entry) return absentFor(entry.key, undefined, null);
+
+ const listed = await readDirectory(directory);
+ const named =
+ (typed !== slug
+ ? listed?.find((candidate) => candidate.slug === typed)
+ : undefined) ??
+ listed?.find(
+ (candidate) =>
+ candidate.slug === slug || candidate.name.toLowerCase() === lower,
+ );
+ if (named) return absentFor(`${idPrefix}${named.slug}`, undefined, listed);
+ return absentFor(isBrokeredId(lower) ? lower : asked, undefined, listed);
+ }
+
+ /** The server halves of a Bot's MCP grants. */
+ async function grantedServerIds(agentId: string): Promise> {
+ const grants = await database
+ .select({ ref: pluginGrants.ref })
+ .from(pluginGrants)
+ .where(
+ and(eq(pluginGrants.kind, "mcp"), eq(pluginGrants.agentId, agentId)),
+ );
+ return new Set(
+ grants.map((grant) => grant.ref.split("/")[0] ?? "").filter(Boolean),
+ );
+ }
+
+ /**
+ * The connection facts for a set of apps, for one person or for nobody.
+ *
+ * THE SAME COLUMNS `GET /connections` REPORTS, read without the broker. A brokered connection is
+ * usable unless a check ran and the vendor refused its key (`probe_action` set, `verified`
+ * false) — the one of `brokeredConnectionsFor`'s four states that is a dead key. A held one is
+ * usable unless its credential was withdrawn.
+ */
+ async function connectionFacts(apps: AppCandidate[], userId: string | null) {
+ const toolkits = [
+ ...new Set(apps.map((app) => app.toolkit).filter((t) => t !== null)),
+ ] as string[];
+ const heldIds = apps
+ .filter((app) => app.provider === "catalogue" && app.present)
+ .flatMap((app) => app.rowIds);
+ const usable = (row: { verified: boolean; probeAction: string | null }) =>
+ !(row.probeAction !== null && !row.verified);
+ const fact = (row: {
+ verified: boolean;
+ probeAction: string | null;
+ connectedAt: Date | null;
+ }) => ({ usable: usable(row), connectedAt: iso(row.connectedAt) });
+
+ const [personal, deployment, held] = await Promise.all([
+ userId && toolkits.length > 0
+ ? database
+ .select({
+ app: brokeredConnections.app,
+ verified: brokeredConnections.verified,
+ probeAction: brokeredConnections.probeAction,
+ connectedAt: brokeredConnections.connectedAt,
+ })
+ .from(brokeredConnections)
+ .where(
+ and(
+ eq(brokeredConnections.provider, brokerProvider),
+ eq(brokeredConnections.holder, "person"),
+ eq(brokeredConnections.userId, userId),
+ inArray(brokeredConnections.app, toolkits),
+ ),
+ )
+ : [],
+ toolkits.length > 0
+ ? database
+ .select({
+ app: brokeredConnections.app,
+ verified: brokeredConnections.verified,
+ probeAction: brokeredConnections.probeAction,
+ connectedAt: brokeredConnections.connectedAt,
+ })
+ .from(brokeredConnections)
+ .where(
+ and(
+ eq(brokeredConnections.provider, brokerProvider),
+ eq(brokeredConnections.holder, "deployment"),
+ inArray(brokeredConnections.app, toolkits),
+ ),
+ )
+ : [],
+ userId && heldIds.length > 0
+ ? database
+ .select({
+ serverId: mcpUserCredentials.serverId,
+ revokedAt: credentialRows.revokedAt,
+ connectedAt: mcpUserCredentials.connectedAt,
+ })
+ .from(mcpUserCredentials)
+ .innerJoin(
+ credentialRows,
+ eq(credentialRows.id, mcpUserCredentials.credentialId),
+ )
+ .where(
+ and(
+ eq(mcpUserCredentials.userId, userId),
+ inArray(mcpUserCredentials.serverId, heldIds),
+ ),
+ )
+ : [],
+ ]);
+ return {
+ personalBrokered: new Map(personal.map((row) => [row.app, fact(row)])),
+ deploymentBrokered: new Map(
+ deployment.map((row) => [row.app, fact(row)]),
+ ),
+ personalHeld: new Map(
+ held.map((row) => [
+ row.serverId,
+ {
+ usable: row.revokedAt === null,
+ connectedAt: iso(row.connectedAt),
+ },
+ ]),
+ ),
+ };
+ }
+
+ /** This person's pending requests about these apps, on this Bot or on no Bot. */
+ async function pendingRequestsFor(
+ userId: string,
+ agentId: string,
+ keys: string[],
+ ) {
+ if (keys.length === 0) return [];
+ return database
+ .select({
+ id: appAccessRequests.id,
+ appKey: appAccessRequests.appKey,
+ kind: appAccessRequests.kind,
+ createdAt: appAccessRequests.createdAt,
+ })
+ .from(appAccessRequests)
+ .where(
+ and(
+ eq(appAccessRequests.requestedBy, userId),
+ eq(appAccessRequests.status, "pending"),
+ inArray(appAccessRequests.appKey, keys),
+ or(
+ eq(appAccessRequests.agentId, agentId),
+ isNull(appAccessRequests.agentId),
+ ),
+ ),
+ )
+ .orderBy(asc(appAccessRequests.createdAt));
+ }
+
+ /**
+ * The owner of a published Team Bot, when the person looking is somebody else; null for any other
+ * Bot, or for its owner. The same publication `credentialActorFor` reads before it decides whose
+ * account a call goes out on.
+ */
+ async function teamBotOwnerFor(
+ agentId: string,
+ userId: string,
+ ): Promise {
+ const [row] = await database
+ .select({ ownerUserId: agentProfiles.ownerUserId })
+ .from(teamBotPublications)
+ .innerJoin(
+ agentProfiles,
+ eq(agentProfiles.agentId, teamBotPublications.agentId),
+ )
+ .where(
+ and(
+ eq(teamBotPublications.agentId, agentId),
+ isNull(agentProfiles.deletedAt),
+ ),
+ )
+ .limit(1);
+ const owner = row?.ownerUserId ?? null;
+ return owner && owner !== userId ? owner : null;
+ }
+
+ /**
+ * The servers this person lets one Team Bot use their own account on without asking: an
+ * "always" or a not-yet-spent "once" for this Bot, or a default for every Bot.
+ */
+ async function standingConsents(
+ userId: string,
+ agentId: string,
+ ): Promise> {
+ const [forBot, forEvery] = await Promise.all([
+ database
+ .select({ serverId: teamBotConsents.serverId })
+ .from(teamBotConsents)
+ .where(
+ and(
+ eq(teamBotConsents.userId, userId),
+ eq(teamBotConsents.agentId, agentId),
+ ),
+ ),
+ database
+ .select({ serverId: teamBotConsentDefaults.serverId })
+ .from(teamBotConsentDefaults)
+ .where(eq(teamBotConsentDefaults.userId, userId)),
+ ]);
+ return new Set([...forBot, ...forEvery].map((row) => row.serverId));
+ }
+
+ /**
+ * Every candidate described for one person and one Bot.
+ *
+ * THE ACCOUNT IS THE ONE THE CALL WILL USE. On somebody else's published Team Bot, a personal
+ * app's calls go out on the owner's account wherever the owner has one — connected, withdrawn or
+ * refused alike, exactly as `credentialActorFor` decides from `connectedServers` — and only
+ * otherwise on the person's own, after they allow it. So the owner's connection decides the state
+ * there, and a teammate is never offered a Connect that changes nothing, nor told `ready` over a
+ * call that will stop to ask them first without saying so.
+ */
+ async function describe(
+ apps: AppCandidate[],
+ input: {
+ userId: string | null;
+ agentId: string;
+ composioConfigured: boolean;
+ sharedAudience?: SharedAudienceCheck;
+ },
+ ): Promise {
+ const { userId } = input;
+ const owner = userId ? await teamBotOwnerFor(input.agentId, userId) : null;
+ const [granted, connections, ownerConnections, consented, pending] =
+ await Promise.all([
+ grantedServerIds(input.agentId),
+ connectionFacts(apps, userId),
+ owner ? connectionFacts(apps, owner) : null,
+ owner && userId
+ ? standingConsents(userId, input.agentId)
+ : new Set(),
+ userId
+ ? pendingRequestsFor(
+ userId,
+ input.agentId,
+ apps.map((app) => app.key),
+ )
+ : [],
+ ]);
+ const personalIn = (
+ found: Awaited>,
+ app: AppCandidate,
+ ) =>
+ brokeredProvenance(app.provider) && app.toolkit
+ ? found.personalBrokered.get(app.toolkit)
+ : app.provider === "catalogue"
+ ? app.rowIds
+ .map((id) => found.personalHeld.get(id))
+ .find((value) => value !== undefined)
+ : undefined;
+ return Promise.all(
+ apps.map(async (app) => {
+ const ids = [app.key, ...app.rowIds];
+ const teamAccount = app.toolkit
+ ? connections.deploymentBrokered.get(app.toolkit)
+ : undefined;
+ const sharedUsable = teamAccount?.usable === true;
+ const own = personalIn(connections, app);
+ const owners = ownerConnections
+ ? personalIn(ownerConnections, app)
+ : undefined;
+ const personal = owners ?? own;
+ const known: AppAccessFacts = {
+ provider: app.provider,
+ present: app.present,
+ composioConfigured: input.composioConfigured,
+ authScheme: app.authScheme,
+ accountMode: app.accountMode,
+ catalogueAuth: app.entry?.auth ?? null,
+ hasCredential: app.hasCredential,
+ granted: ids.some((id) => granted.has(id)),
+ personal: personal === undefined ? null : { usable: personal.usable },
+ shared: teamAccount ? { usable: sharedUsable } : null,
+ sharedAdmitted: null,
+ };
+ const account = accountOf(known);
+ /*
+ * Asked only where it can change the answer: a Shared app this Bot holds, whose team account
+ * is there, for a named person. The gate is asked again on every call; this is what it would
+ * say now.
+ */
+ const facts: AppAccessFacts =
+ account === "shared" &&
+ sharedUsable &&
+ known.granted &&
+ userId &&
+ input.sharedAudience &&
+ app.answeringId
+ ? {
+ ...known,
+ sharedAdmitted: await input.sharedAudience({
+ botId: input.agentId,
+ serverId: app.answeringId,
+ actorId: userId,
+ }),
+ }
+ : known;
+ const state = appAccessState(facts);
+ const holder = owners !== undefined ? "owner" : "you";
+ const requestKind = REQUEST_KIND_FOR[state];
+ const request =
+ requestKind === null
+ ? undefined
+ : pending.find(
+ (row) => row.appKey === app.key && row.kind === requestKind,
+ );
+ return {
+ access: {
+ key: app.key,
+ displayName: app.title,
+ logo: app.logo,
+ provider: app.provider,
+ account,
+ connect: connectOf(facts),
+ authScheme: brokeredProvenance(app.provider)
+ ? app.authScheme
+ : null,
+ accountMode: app.accountMode,
+ state,
+ unavailable: unavailableReason(facts),
+ accountHolder: account === "personal" ? holder : null,
+ asksFirst:
+ state === "ready" &&
+ account === "personal" &&
+ holder === "you" &&
+ owner !== null &&
+ !ids.some((id) => consented.has(id)),
+ granted: facts.granted,
+ connectedAt:
+ account === "shared"
+ ? (teamAccount?.connectedAt ?? null)
+ : account === "personal"
+ ? (personal?.connectedAt ?? null)
+ : null,
+ requestKind,
+ request: request
+ ? {
+ id: request.id,
+ kind: request.kind,
+ createdAt: iso(request.createdAt) ?? "",
+ }
+ : null,
+ },
+ unavailable: unavailableReason(facts),
+ answeringId: app.answeringId,
+ toolkit: app.toolkit,
+ entry: app.entry,
+ sharedUsable,
+ vouched: app.vouched,
+ };
+ }),
+ );
+ }
+
+ const requestColumns = {
+ request: appAccessRequests,
+ botName: agents.name,
+ requesterEmail: users.email,
+ requesterName: users.name,
+ };
+
+ function toRecord(row: {
+ request: typeof appAccessRequests.$inferSelect;
+ botName: string | null;
+ requesterEmail: string | null;
+ requesterName: string | null;
+ }): AppAccessRequestRecord {
+ return {
+ id: row.request.id,
+ appKey: row.request.appKey,
+ appTitle: row.request.appTitle,
+ kind: row.request.kind,
+ status: row.request.status,
+ agentId: row.request.agentId,
+ botName: row.botName,
+ requestedBy: row.request.requestedBy,
+ requesterEmail: row.requesterEmail,
+ requesterName: row.requesterName,
+ createdAt: iso(row.request.createdAt) ?? "",
+ resolvedAt: iso(row.request.resolvedAt),
+ resolvedBy: row.request.resolvedBy,
+ };
+ }
+
+ async function requestRows(where: SQL | undefined) {
+ const rows = await database
+ .select(requestColumns)
+ .from(appAccessRequests)
+ .leftJoin(agents, eq(agents.id, appAccessRequests.agentId))
+ .leftJoin(users, eq(users.id, appAccessRequests.requestedBy))
+ .where(where)
+ .orderBy(desc(appAccessRequests.createdAt), asc(appAccessRequests.id));
+ return rows.map(toRecord);
+ }
+
+ /** See `closeAppAccessRequest` below. */
+ async function closeRequest(input: {
+ id: string;
+ status: Exclude;
+ by: string;
+ requestedBy?: string;
+ }): Promise {
+ const [closed] = await database
+ .update(appAccessRequests)
+ .set({
+ status: input.status,
+ resolvedAt: new Date(),
+ resolvedBy: input.by,
+ })
+ .where(
+ and(
+ eq(appAccessRequests.id, input.id),
+ eq(appAccessRequests.status, "pending"),
+ input.requestedBy
+ ? eq(appAccessRequests.requestedBy, input.requestedBy)
+ : undefined,
+ ),
+ )
+ .returning({ id: appAccessRequests.id });
+ if (!closed) return null;
+ const [record] = await requestRows(eq(appAccessRequests.id, closed.id));
+ return record ?? null;
+ }
+
+ return {
+ /**
+ * Every work app one Bot holds, each with what it is for this person.
+ *
+ * HELD MEANS A GRANT, WHETHER OR NOT THE APP IS STILL THERE. A grant whose row is gone — an app
+ * removed before removal took its grants with it, or restored from an older backup — is still
+ * something this Bot was given, and is listed as switched off rather than dropped, because
+ * "this Bot used to reach Gmail and cannot now" is the one thing a person on the Bot's page
+ * needs to read about it.
+ *
+ * Sorted by name, so two loads draw one order.
+ */
+ async appAccessForBot(input: {
+ userId: string;
+ agentId: string;
+ composioConfigured: boolean;
+ directory?: AppDirectory;
+ sharedAudience?: SharedAudienceCheck;
+ }): Promise {
+ const granted = await grantedServerIds(input.agentId);
+ if (granted.size === 0) return [];
+ const byRow = await appsByRow(await allRows());
+ const apps = new Map();
+ const missing: string[] = [];
+ for (const serverId of granted) {
+ const app = byRow.get(serverId);
+ if (app) apps.set(app.key, app);
+ else missing.push(serverId);
+ }
+ const listed = missing.some(isBrokeredId)
+ ? await readDirectory(input.directory)
+ : null;
+ for (const serverId of missing) {
+ const app =
+ (await absentFor(serverId, undefined, listed)) ??
+ absent({ key: serverId, title: serverId, provider: "custom" });
+ if (!apps.has(app.key)) apps.set(app.key, app);
+ }
+ const described = await describe([...apps.values()], input);
+ return described
+ .map((item) => item.access)
+ .sort((left, right) =>
+ left.displayName.localeCompare(right.displayName),
+ );
+ },
+
+ /**
+ * One app, by whatever a caller called it, for this Bot and — where `userId` is given — this
+ * person. Null for a word that names no app.
+ *
+ * Used by the chat card, which asks about an app the Bot may hold nothing for, and by the
+ * approve route, which asks for the deployment's half alone and passes no person.
+ */
+ async inspectApp(input: {
+ app: string;
+ agentId: string;
+ userId: string | null;
+ composioConfigured: boolean;
+ directory?: AppDirectory;
+ sharedAudience?: SharedAudienceCheck;
+ }): Promise {
+ const candidate = await candidateFor(
+ input.app,
+ input.directory,
+ await grantedServerIds(input.agentId),
+ );
+ if (!candidate) return null;
+ const [described] = await describe([candidate], input);
+ return described ?? null;
+ },
+
+ /**
+ * Every tool the app's answering row advertises, as grant refs, and whether each is a read.
+ *
+ * `read` IS THE EXACT WORD AND NOTHING ELSE, as `classifyTool` reads `effect`: a null or an
+ * unrecognised value is a write, so a tool nobody classified is never handed out as a read.
+ */
+ async appTools(
+ serverId: string,
+ ): Promise<{ ref: string; read: boolean }[]> {
+ const rows = await database
+ .select({ name: mcpTools.name, effect: mcpTools.effect })
+ .from(mcpTools)
+ .where(eq(mcpTools.serverId, serverId))
+ .orderBy(asc(mcpTools.name));
+ return rows.map((row) => ({
+ ref: `${serverId}/${row.name}`,
+ read: row.effect === "read",
+ }));
+ },
+
+ /**
+ * File a request, or hand back the one already pending.
+ *
+ * THE INDEX DECIDES, NOT A READ. `on conflict do nothing` against the partial unique index is
+ * what keeps two replicas filing the same request from inserting twice; the read after it only
+ * finds which row won. A request answered between the two is filed again, once.
+ */
+ async fileAppAccessRequest(input: {
+ requestedBy: string;
+ agentId: string | null;
+ appKey: string;
+ appTitle: string;
+ kind: AppAccessRequestKind;
+ }): Promise<{ request: AppAccessRequestRecord; created: boolean }> {
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ const id = randomUUID();
+ const inserted = await database
+ .insert(appAccessRequests)
+ .values({ id, ...input })
+ .onConflictDoNothing()
+ .returning({ id: appAccessRequests.id });
+ const [request] = await requestRows(
+ inserted.length > 0
+ ? eq(appAccessRequests.id, id)
+ : and(
+ eq(appAccessRequests.requestedBy, input.requestedBy),
+ eq(appAccessRequests.appKey, input.appKey),
+ eq(appAccessRequests.kind, input.kind),
+ eq(appAccessRequests.status, "pending"),
+ input.agentId === null
+ ? isNull(appAccessRequests.agentId)
+ : eq(appAccessRequests.agentId, input.agentId),
+ ),
+ );
+ if (request) {
+ if (inserted.length > 0)
+ await recordAuditEvent(auditStore, {
+ eventType: "app_access.requested",
+ targetType: "mcp_server",
+ targetId: input.appKey,
+ actorUserId: input.requestedBy,
+ payload: {
+ request: id,
+ app: input.appKey,
+ bot: input.agentId,
+ kind: input.kind,
+ },
+ });
+ return { request, created: inserted.length > 0 };
+ }
+ }
+ throw new Error(
+ `A request for ${input.appKey} was neither filed nor found pending.`,
+ );
+ },
+
+ /** How many requests this person has waiting, across every app and Bot. */
+ async countPendingAppAccessRequests(requestedBy: string): Promise {
+ const [row] = await database
+ .select({ count: count() })
+ .from(appAccessRequests)
+ .where(
+ and(
+ eq(appAccessRequests.requestedBy, requestedBy),
+ eq(appAccessRequests.status, "pending"),
+ ),
+ );
+ return row?.count ?? 0;
+ },
+
+ /**
+ * Claim a pending request for an approval about to be carried out: closed as approved, by this
+ * administrator, before anything is changed — or null when somebody else answered it first.
+ *
+ * THE CLAIM COMES FIRST SO NOTHING CAN ANSWER IT UNDERNEATH THE CHANGE. Approving switches an
+ * app on and grants a Bot its tools, and used to close the request only afterwards; a dismiss or
+ * a withdrawal landing in between closed it first, the approve's close found nothing, and the
+ * Bot kept a grant under a request that read dismissed, with no `app_access.approved` in the
+ * trail. Claimed here by the same conditional update every close is, the approval is the answer
+ * the moment it starts, and anybody else closing meanwhile finds it answered and loses with a
+ * 409 — before the change, not after it.
+ *
+ * NOT A ROW LOCK HELD ACROSS THE CHANGE, which is the other way to get this and the wrong one
+ * here. The change is made through the store on connections of its own, and a transaction kept
+ * open around it is the shape `db/client.ts` warns about: every pooled connection inside such a
+ * transaction, each waiting for a second, is a hang under load and an immediate one on a pool
+ * of one.
+ *
+ * An approval that cannot be carried out puts the request back with
+ * {@link releaseAppAccessRequest}, so it waits again with the step that is missing.
+ */
+ async claimAppAccessRequest(input: {
+ id: string;
+ by: string;
+ }): Promise {
+ return closeRequest({ id: input.id, status: "approved", by: input.by });
+ },
+
+ /**
+ * Put back a request {@link claimAppAccessRequest} claimed and the approval then could not carry
+ * out. Only this administrator's claim, and only while it still reads approved.
+ *
+ * A REQUEST FILED AGAIN MEANWHILE IS THE ONE THAT WAITS. The person saw theirs answered, and the
+ * partial unique index lets them file the same request again; two cannot both be pending, so
+ * this one is closed as cancelled instead — the same request, already waiting under the other id.
+ */
+ async releaseAppAccessRequest(input: { id: string; by: string }) {
+ const claimed = and(
+ eq(appAccessRequests.id, input.id),
+ eq(appAccessRequests.status, "approved"),
+ eq(appAccessRequests.resolvedBy, input.by),
+ );
+ try {
+ await database
+ .update(appAccessRequests)
+ .set({ status: "pending", resolvedAt: null, resolvedBy: null })
+ .where(claimed);
+ } catch (error) {
+ if (!isUniqueViolation(error)) throw error;
+ await database
+ .update(appAccessRequests)
+ .set({ status: "cancelled", resolvedAt: new Date() })
+ .where(claimed);
+ }
+ },
+
+ async appAccessRequest(id: string): Promise {
+ const [request] = await requestRows(eq(appAccessRequests.id, id));
+ return request ?? null;
+ },
+
+ /** Pending requests: one person's, one app's, or every one. Newest first. */
+ async pendingAppAccessRequests(filter: {
+ requestedBy?: string;
+ appKey?: string;
+ }): Promise {
+ return requestRows(
+ and(
+ eq(appAccessRequests.status, "pending"),
+ filter.requestedBy
+ ? eq(appAccessRequests.requestedBy, filter.requestedBy)
+ : undefined,
+ filter.appKey
+ ? eq(appAccessRequests.appKey, filter.appKey)
+ : undefined,
+ ),
+ );
+ },
+
+ /**
+ * Close a pending request, and only a pending one.
+ *
+ * THE CONDITION IS THE WHOLE OF THE CONCURRENCY STORY. Two administrators answering at once,
+ * or a person cancelling while an administrator approves, both run this; the first update
+ * finds the row pending and the second finds nothing, and null is how its caller learns it
+ * lost. `requestedBy`, when given, confines a cancel to the person's own requests in the same
+ * statement.
+ */
+ closeAppAccessRequest: closeRequest,
+
+ /**
+ * A row in the trail about a request, written here so the routes write through the same
+ * redacting writer every other plugin event uses.
+ */
+ async recordAppAccessEvent(
+ event: Omit & {
+ eventType:
+ | "app_access.cancelled"
+ | "app_access.approved"
+ | "app_access.dismissed"
+ | "app_access.refused";
+ },
+ ): Promise {
+ await recordAuditEvent(auditStore, {
+ ...event,
+ targetType: "mcp_server",
+ });
+ },
+ };
+}
diff --git a/server/src/plugins/plugin-store/calls.ts b/server/src/plugins/plugin-store/calls.ts
index b7666527b2..97e6700f3e 100644
--- a/server/src/plugins/plugin-store/calls.ts
+++ b/server/src/plugins/plugin-store/calls.ts
@@ -1,3 +1,4 @@
+import { connectAppRefusal } from "@openbot/shared/connect-prompt";
import { and, eq } from "drizzle-orm";
import { currentApprovalContext } from "../../approvals/types";
import { type AuditInitiator, recordAuditEvent } from "../../audit";
@@ -7,7 +8,12 @@ import {
policyInitiator,
} from "../../computer/policy";
import { withoutStatement } from "../../db/query-failure";
-import { mcpTools, pluginGrants, vendorNameOf } from "../../db/schema";
+import {
+ brokeredConnections,
+ mcpTools,
+ pluginGrants,
+ vendorNameOf,
+} from "../../db/schema";
import { classifyTool, tokenHeaderFor } from "../catalogue";
import { VERSION_ARG } from "../composio";
import { inspectToolArguments } from "../content-governance";
@@ -35,6 +41,7 @@ export function callMethods(context: StoreContext) {
injectedVendor,
connectionTokenFor,
requireServer,
+ brokerProvider,
} = context;
return {
/**
@@ -524,7 +531,11 @@ export function callMethods(context: StoreContext) {
});
const answer = { text: result.text, isError: result.isError };
await approval?.complete(answer);
- return answer;
+ /*
+ * The one answer that leaves this block without returning: see below the catch. Thrown
+ * from in here, the catch would write a second `mcp.call_failed` row about the same call.
+ */
+ if (!result.accountGone) return answer;
} catch (error) {
/*
* Recorded, then rethrown unchanged. The caller's behaviour is unaffected — what changes is
@@ -560,6 +571,57 @@ export function callMethods(context: StoreContext) {
});
throw error;
}
+ /*
+ * THE ACCOUNT IS GONE AT THE VENDOR, AND THE PERSON CAN PUT IT BACK FROM WHERE THEY ARE. The
+ * only way here: every other answer returned inside the block and every throw left the catch.
+ *
+ * Recorded above as the failure it is, with the vendor's condition in the trail. Handed on as
+ * a refusal carrying an app card rather than as the vendor's error, because nothing on this
+ * deployment's rows knows the account is gone — the app reads as ready on every screen — and
+ * the card is the one place the person is told, with Reconnect beside it. A Shared app's
+ * account is the deployment's, so there the card says an administrator has to reconnect it.
+ *
+ * AND THE ROW GOES, as it does when a confirm hears the same no from the vendor: the row is
+ * only the vendor's last answer, and one that outlived the account would go on reading
+ * `ready` on every screen — `show_app_access` telling the model to use the app straight after
+ * this card — and would make the connect route refuse a fresh connection until somebody
+ * found Disconnect. Deleted, the app reads not connected for the person, or the team account
+ * not connected for everybody, which is the truth and the state whose button fixes it. Like
+ * the confirm's, the delete files no trail entry: nobody disconnected anything here.
+ */
+ if (access.credential === "brokered" && access.toolkit)
+ await database
+ .delete(brokeredConnections)
+ .where(
+ gatedMode === "shared"
+ ? and(
+ eq(brokeredConnections.provider, brokerProvider),
+ eq(brokeredConnections.app, access.toolkit),
+ eq(brokeredConnections.holder, "deployment"),
+ )
+ : and(
+ eq(brokeredConnections.provider, brokerProvider),
+ eq(brokeredConnections.app, access.toolkit),
+ eq(brokeredConnections.holder, "person"),
+ eq(
+ brokeredConnections.userId,
+ input.credentialActorId ?? input.actorId,
+ ),
+ ),
+ );
+ throw new PluginRefusedError(
+ connectAppRefusal({
+ serverId: row.id,
+ title: row.title,
+ message:
+ gatedMode === "shared"
+ ? `The shared ${row.title} account is no longer connected at the vendor, so this was not done. An administrator has to connect it again.`
+ : `Your ${row.title} account is no longer connected at the vendor, so this was not done. Reconnect it and ask again.`,
+ reason: gatedMode === "shared" ? "shared" : "reconnect",
+ at: new Date().toISOString(),
+ }),
+ null,
+ );
},
};
}
diff --git a/server/src/plugins/plugin-store/context.ts b/server/src/plugins/plugin-store/context.ts
index 544ee84f86..dfd905e1e5 100644
--- a/server/src/plugins/plugin-store/context.ts
+++ b/server/src/plugins/plugin-store/context.ts
@@ -44,6 +44,7 @@ import {
connectionSiblings,
exchangeRefreshTokenOverHttp,
INVALID_CLIENT,
+ INVALID_GRANT,
isUsableClient,
type OAuthClient,
PluginInvariantError,
@@ -661,13 +662,22 @@ export function createStoreContext(options: PluginStoreOptions) {
.limit(1);
if (!connected) {
/*
- * Only the personal half carries a connect prompt. A Shared app's account belongs to the
- * deployment, so the person reading this cannot connect it however clearly they are asked —
- * a card offering them the button would be an invitation to a refusal.
+ * BOTH HALVES CARRY A PROMPT, AND ONLY ONE OF THEM OFFERS A CONNECT BUTTON. A Shared app's
+ * account belongs to the deployment, so the person reading this cannot connect it however
+ * clearly they are asked — a card offering them that button would be an invitation to a
+ * refusal. What the card draws is decided by the app's state for this person, read when it
+ * is drawn: for a missing team account that is Request access, which files the request an
+ * administrator answers by connecting it. The sentence stays for the model and for a reader
+ * with no card. `reason: "shared"` is only a hint; see `ConnectReason`.
*/
throw new PluginRefusedError(
resolved.ref.holder === "deployment"
- ? `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.`
+ ? connectAppRefusal({
+ serverId: row.id,
+ title: row.title,
+ message: `${row.title} is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.`,
+ reason: "shared",
+ })
: connectAppRefusal({
serverId: row.id,
title: row.title,
@@ -928,6 +938,8 @@ export function createStoreContext(options: PluginStoreOptions) {
* token it had read on the way in would present the one the first replica just spent, which is
* the very double-spend this exists to prevent.
*/
+ /** The credential whose refresh token went to the vendor, once one has. */
+ let presented: string | null = null;
try {
return await database.transaction(async (transaction) => {
const [current] = await transaction
@@ -985,6 +997,7 @@ export function createStoreContext(options: PluginStoreOptions) {
locked.encryptedValue,
);
+ presented = current.credentialId;
const minted = await exchangeRefreshToken({
tokenUrl,
client: stored.client,
@@ -1018,6 +1031,31 @@ export function createStoreContext(options: PluginStoreOptions) {
return { token: minted.accessToken };
});
} catch (error) {
+ /*
+ * THE VENDOR DISOWNED THE GRANT, NOT THE CLIENT. A refresh token answered `invalid_grant`
+ * will never be honoured again, and until now nothing here wrote that down: the connection
+ * read as connected on every screen, and every call came back as the same folded tool
+ * error. Revoked here — after the transaction, for the reason the recovery below gives — the
+ * connection reads as withdrawn, which is Reconnect on the Bot page and on the card this
+ * refusal draws, and the consent that fixes it replaces the credential as any reconnect does.
+ */
+ if (
+ error instanceof TokenRefusedError &&
+ error.code === INVALID_GRANT &&
+ presented
+ ) {
+ await credentials.revoke(presented);
+ throw new PluginRefusedError(
+ connectAppRefusal({
+ serverId: row.id,
+ title,
+ message: `Your ${title} access was withdrawn at the vendor, so this was not done. Reconnect it and ask again.`,
+ reason: "reconnect",
+ at: new Date().toISOString(),
+ }),
+ null,
+ );
+ }
/*
* Outside the transaction, so the row lock is already released and the vault read this does
* is not a second connection held behind this one's.
diff --git a/server/src/plugins/plugin-store/model.ts b/server/src/plugins/plugin-store/model.ts
index 01d0100b12..b961c4712a 100644
--- a/server/src/plugins/plugin-store/model.ts
+++ b/server/src/plugins/plugin-store/model.ts
@@ -749,6 +749,14 @@ export class TokenRefusedError extends McpServerError {
*/
export const INVALID_CLIENT = "invalid_client";
+/**
+ * The OAuth error code for a refresh token the vendor will no longer honour (RFC 6749 §5.2): it was
+ * revoked, it expired, or the person withdrew the grant. Nothing this deployment can do brings it
+ * back — only the person consenting again — so the connection is recorded as withdrawn, the state
+ * every screen already reads as Reconnect.
+ */
+export const INVALID_GRANT = "invalid_grant";
+
/**
* A transaction, as the writes in this module hand one to each other.
*
@@ -1176,7 +1184,8 @@ export type PluginStoreOptions = {
},
toolName: string,
args: Record,
- ) => Promise<{ text: string; isError: boolean }>;
+ /** `accountGone` as `McpCallResult` carries it: the vendor lost the account this call ran in. */
+ ) => Promise<{ text: string; isError: boolean; accountGone?: true }>;
/** Trading a refresh token for a short-lived access token. Defaults to a real HTTP exchange. */
exchangeRefreshToken?: (input: {
tokenUrl: string;
diff --git a/server/src/plugins/route-groups/app-access.ts b/server/src/plugins/route-groups/app-access.ts
new file mode 100644
index 0000000000..684591c0f6
--- /dev/null
+++ b/server/src/plugins/route-groups/app-access.ts
@@ -0,0 +1,645 @@
+import type { Context } from "hono";
+import { type AppVariables, requireAdmin } from "../../auth/guards";
+import { BROKERED_PROFILE, type BrokerProvider } from "../access";
+import type { Unavailable } from "../app-access";
+import type { CatalogueEntry } from "../catalogue";
+import type {
+ AppAccessRequestRecord,
+ AppDirectory,
+} from "../plugin-store/app-access";
+import {
+ CatalogueEntryUnknownError,
+ CustomServerRefusedError,
+ deploymentFaultSentence,
+ isDeploymentFault,
+} from "../store";
+import { enableComposioApp } from "./composio-apps";
+import type { PluginRouteContext } from "./context";
+import { sharedGrantApproval } from "./grants";
+
+type RouteContext = Context<{ Variables: AppVariables }>;
+
+/**
+ * The longest app name or Bot id a request may carry — the same cap the `show_app_access` tool puts
+ * on what a model may ask about. Both are written into the trail when a filing is refused, and an
+ * app's name becomes the title an administrator reads in the inbox, so neither is a field a caller
+ * may fill with whatever they like.
+ */
+const MAX_NAME = 200;
+
+/**
+ * How many requests one person may have waiting at once. A real person asks about the handful of
+ * apps a question needed; past this the inbox is being filled rather than asked, and each extra row
+ * is one an administrator has to answer by hand.
+ */
+export const MAX_PENDING_REQUESTS = 20;
+
+/**
+ * What enabling a catalogue entry would need that only an administrator can type, or null when
+ * adding its row is the whole of it.
+ *
+ * A vendor that gives every customer their own host needs that host; a deployment token needs the
+ * token; an OAuth client that does not register itself needs the client. Approving a request
+ * cannot invent any of those, so it says which one is missing and leaves the request pending.
+ */
+function adminInputFor(entry: CatalogueEntry): string | null {
+ if (entry.host === null) return `the address of your ${entry.title} instance`;
+ if (entry.auth.kind === "deployment-bearer")
+ return "the token it is reached with";
+ if (
+ entry.auth.kind === "user-oauth" &&
+ entry.auth.clientRegistration !== "dynamic"
+ )
+ return "an OAuth client registered with the vendor";
+ return null;
+}
+
+/**
+ * The sentence an approval is refused with when the app is there and still cannot be used.
+ *
+ * The broker's name and key come from {@link BROKERED_PROFILE}, so an Arcade deployment is told
+ * to set ARCADE_API_KEY in the words a Composio one is told to set COMPOSIO_API_KEY.
+ */
+function unavailableSentence(
+ reason: Unavailable,
+ title: string,
+ provider: BrokerProvider,
+): string {
+ switch (reason) {
+ case "absent":
+ return `${title} is not an app this deployment can switch on from here.`;
+ case "composio_unconfigured": {
+ const { vendor, envVar } = BROKERED_PROFILE[provider];
+ const article = /^[aeiou]/i.test(vendor) ? "an" : "a";
+ return `${title} is ${article} ${vendor} app, and this deployment has no ${vendor} key. Set ${envVar}, then approve this request again.`;
+ }
+ case "scheme_unreadable":
+ return `${title} is enabled, but how it connects is not recorded in a way this deployment can read. Remove it and enable it again on its page under Plugins, then approve this request again.`;
+ case "oauth_client_missing":
+ return `${title} is added, but has no OAuth client yet, so nobody can connect it. Register one on its page under Plugins, then approve this request again.`;
+ case "token_missing":
+ return `${title} is added, but has no token yet. Add one on its page under Plugins, then approve this request again.`;
+ }
+}
+
+/**
+ * Where a person sees which work apps a Bot can use for them, asks an administrator for the ones
+ * only an administrator can fix, and where the administrator answers.
+ *
+ * THE STATE IS ALWAYS THE SERVER'S. A person's browser names a Bot and an app; what the app is for
+ * them, and therefore what a request asks for, is computed here from the same tables every gate
+ * reads. A request cannot be filed for a state the app is not in, and approving one does the thing
+ * that state was missing — switching the app on, granting it to the Bot — in the same press.
+ *
+ * WHY NOT `shared_use_requests`. That table answers a different question: a Shared app whose team
+ * account IS connected, and whether an administrator approves the audience a Bot exposes it to.
+ * "Please connect the team account" is a request about the account itself, before any audience can
+ * matter, and has none of that table's columns to fill. The style, the conditional decide and the
+ * audit vocabulary are that flow's; the rows are not.
+ */
+export function mountAppAccessRoutes({
+ routes,
+ store,
+ requireUser,
+ canUseBot,
+ composio,
+ shared,
+ actorEmail,
+ activeProvider,
+ activeVendor,
+}: PluginRouteContext) {
+ const composioConfigured = Boolean(composio);
+ /*
+ * Asked only to name an app this deployment has no row for — see `readDirectory` in the store.
+ * The adapter caches the listing, so a card asking about an app nobody enabled costs a vendor
+ * call at most once per cache window rather than once per render.
+ */
+ const directory: AppDirectory | undefined = composio
+ ? () => composio.broker.listApps()
+ : undefined;
+ /*
+ * What the shared-use gate would answer this person's chat through this Bot, so a Shared app
+ * whose audience was never approved reads as refused rather than ready. Without the store there
+ * is no gate to ask, and the call path refuses every Shared call anyway; the state says `ready`
+ * there exactly as it did before this was asked.
+ */
+ const sharedAudience = shared?.use.admitsPerson;
+
+ const refused = async (
+ context: RouteContext,
+ input: {
+ app: string;
+ reason: string;
+ error: string;
+ status: 400 | 403 | 404 | 409 | 429 | 502 | 503;
+ request?: string;
+ bot?: string | null;
+ kind?: string;
+ },
+ ) => {
+ await store.recordAppAccessEvent({
+ eventType: "app_access.refused",
+ targetId: input.app,
+ actorUserId: context.var.actor.id,
+ payload: {
+ app: input.app,
+ reason: input.reason,
+ ...(input.request ? { request: input.request } : {}),
+ ...(input.bot !== undefined ? { bot: input.bot } : {}),
+ ...(input.kind ? { kind: input.kind } : {}),
+ },
+ });
+ return context.json({ error: input.error }, input.status);
+ };
+
+ /**
+ * Every work app this Bot holds, with what each one is for the person asking.
+ *
+ * Only for a Bot the person may use, and answered 404 otherwise, for the reason the grant list
+ * beside it gives: what a private Bot holds is a fact about that Bot.
+ */
+ routes.get("/for/:agentId/apps", requireUser, async (context) => {
+ const agentId = context.req.param("agentId");
+ if (!(await canUseBot(context.var.actor, agentId)))
+ return context.json({ error: "There is no such Bot." }, 404);
+ return context.json({
+ apps: await store.appAccessForBot({
+ userId: context.var.actor.id,
+ agentId,
+ composioConfigured,
+ directory,
+ sharedAudience,
+ }),
+ });
+ });
+
+ /**
+ * One app, by key or by name, whether or not this Bot holds it — so a card in a conversation can
+ * say an app is switched off or not granted, and offer the request that fixes it.
+ */
+ routes.get("/for/:agentId/apps/:app", requireUser, async (context) => {
+ const agentId = context.req.param("agentId");
+ if (!(await canUseBot(context.var.actor, agentId)))
+ return context.json({ error: "There is no such Bot." }, 404);
+ const app = context.req.param("app");
+ const inspected =
+ app.length > MAX_NAME
+ ? null
+ : await store.inspectApp({
+ app,
+ agentId,
+ userId: context.var.actor.id,
+ composioConfigured,
+ directory,
+ sharedAudience,
+ });
+ if (!inspected)
+ return context.json(
+ { error: `This deployment has no app called ${app}.` },
+ 404,
+ );
+ return context.json({ app: inspected.access });
+ });
+
+ /**
+ * Ask an administrator for access. The body names the Bot and the app; the kind is the server's.
+ *
+ * A SECOND PRESS IS THE FIRST REQUEST. One pending request per person, app, Bot and kind, so
+ * asking again answers with the one already waiting and `created: false`, and writes no second
+ * trail row.
+ *
+ * ONLY FOR AN APP SOMETHING VOUCHES FOR, AND ONLY SO MANY. Asking to enable a `composio-` id the
+ * directory does not list would put whatever the caller typed in front of an administrator as an
+ * app's name, for an approval that can only fail; it is answered as no such app, unless the Bot
+ * already holds a grant for it and the request is about putting back something it was given. And
+ * a person with {@link MAX_PENDING_REQUESTS} waiting is told to wait for those to be answered.
+ */
+ routes.post("/access-requests", requireUser, async (context) => {
+ const body = (await context.req.json().catch(() => null)) as {
+ agentId?: unknown;
+ app?: unknown;
+ } | null;
+ const agentId =
+ typeof body?.agentId === "string" ? body.agentId.trim() : "";
+ const app = typeof body?.app === "string" ? body.app.trim() : "";
+ if (!agentId || !app)
+ return context.json({ error: "Name the Bot and the app." }, 400);
+ if (agentId.length > MAX_NAME || app.length > MAX_NAME)
+ return context.json(
+ {
+ error: `A Bot or an app is named in at most ${MAX_NAME} characters.`,
+ },
+ 400,
+ );
+ if (!(await canUseBot(context.var.actor, agentId)))
+ return refused(context, {
+ app,
+ bot: agentId,
+ reason: "no_such_bot",
+ error: "There is no such Bot.",
+ status: 404,
+ });
+ const found = await store.inspectApp({
+ app,
+ agentId,
+ userId: context.var.actor.id,
+ composioConfigured,
+ directory,
+ sharedAudience,
+ });
+ const inspected =
+ found && (found.vouched || found.access.granted) ? found : null;
+ if (!inspected)
+ return refused(context, {
+ app,
+ bot: agentId,
+ reason: "no_such_app",
+ error: `This deployment has no app called ${app}.`,
+ status: 404,
+ });
+ const { access } = inspected;
+ if (!access.requestKind)
+ return refused(context, {
+ app: access.key,
+ bot: agentId,
+ reason: `nothing_to_request:${access.state}`,
+ error:
+ access.state === "ready"
+ ? `${access.displayName} is ready to use. There is nothing to ask an administrator for.`
+ : `${access.displayName} is waiting on your own account, not on an administrator. Connect it yourself.`,
+ status: 409,
+ });
+ /*
+ * The one already waiting is answered as itself before the cap is counted, so a person at the
+ * cap pressing again on a request they filed is shown it, not refused.
+ */
+ if (
+ !access.request &&
+ (await store.countPendingAppAccessRequests(context.var.actor.id)) >=
+ MAX_PENDING_REQUESTS
+ )
+ return refused(context, {
+ app: access.key,
+ bot: agentId,
+ reason: "too_many_pending",
+ kind: access.requestKind,
+ error: `You already have ${MAX_PENDING_REQUESTS} requests waiting for an administrator. Withdraw one, or wait for them to be answered, then ask again.`,
+ status: 429,
+ });
+ const filed = await store.fileAppAccessRequest({
+ requestedBy: context.var.actor.id,
+ agentId,
+ appKey: access.key,
+ appTitle: access.displayName,
+ kind: access.requestKind,
+ });
+ return context.json(filed, filed.created ? 201 : 200);
+ });
+
+ /** The person's own pending requests, newest first. */
+ routes.get("/access-requests/mine", requireUser, async (context) =>
+ context.json({
+ requests: await store.pendingAppAccessRequests({
+ requestedBy: context.var.actor.id,
+ }),
+ }),
+ );
+
+ /**
+ * Withdraw one's own pending request. Somebody else's reads exactly like one that does not exist,
+ * so this route cannot be used to learn who asked for what.
+ */
+ routes.delete("/access-requests/:id", requireUser, async (context) => {
+ const id = context.req.param("id");
+ const closed = await store.closeAppAccessRequest({
+ id,
+ status: "cancelled",
+ by: context.var.actor.id,
+ requestedBy: context.var.actor.id,
+ });
+ if (!closed) {
+ const existing = await store.appAccessRequest(id);
+ const mine = existing?.requestedBy === context.var.actor.id;
+ return refused(context, {
+ app: mine ? (existing?.appKey ?? "") : "",
+ request: id,
+ reason: mine ? "already_resolved" : "no_such_request",
+ error: mine
+ ? "That request has already been answered."
+ : "There is no such request.",
+ status: mine ? 409 : 404,
+ });
+ }
+ await store.recordAppAccessEvent({
+ eventType: "app_access.cancelled",
+ targetId: closed.appKey,
+ actorUserId: context.var.actor.id,
+ payload: {
+ request: closed.id,
+ app: closed.appKey,
+ bot: closed.agentId,
+ kind: closed.kind,
+ },
+ });
+ return context.json({ request: closed });
+ });
+
+ /**
+ * Every pending request, for an administrator: one app's with `?app=`, or all of them, and a
+ * count per app either way so the Plugins list can badge each app without a second read.
+ */
+ routes.get("/access-requests", requireUser, async (context) => {
+ const forbidden = requireAdmin(context);
+ if (forbidden) return forbidden;
+ const all = await store.pendingAppAccessRequests({});
+ const byApp: Record = {};
+ for (const request of all)
+ byApp[request.appKey] = (byApp[request.appKey] ?? 0) + 1;
+ const app = context.req.query("app")?.trim();
+ return context.json({
+ requests: app ? all.filter((request) => request.appKey === app) : all,
+ counts: { total: all.length, byApp },
+ });
+ });
+
+ /**
+ * Approve a request by doing what it asks: switch the app on, grant its tools to the Bot, or —
+ * for a team account, which only a consent screen can connect — confirm it has been connected.
+ *
+ * CLAIMED, THEN CHANGED. The request is closed as approved by this administrator before anything
+ * is changed, so a dismiss, a withdrawal or a second approval arriving meanwhile finds it answered
+ * and loses, and the change can never land under a request somebody else answered. A change that
+ * cannot be carried out puts the request back, and it waits again with the step that is missing;
+ * so does one that throws. See `claimAppAccessRequest` in the store, which says why this is not a
+ * row lock held across the change.
+ *
+ * `refs` NARROWS THE GRANT, AND WITHOUT IT ONLY THE READS ARE GRANTED. One press in the inbox
+ * gives the Bot every action the app has marked read-only — what the Bot page's bulk switch
+ * offers, and nothing that sends, writes or deletes. Anything more is chosen action by action, on
+ * the Bot's page for the app or by naming them here. An app with no read-only action is refused
+ * with that sentence rather than granted everything. A ref outside the app is refused rather than
+ * dropped.
+ */
+ routes.post("/access-requests/:id/approve", requireUser, async (context) => {
+ const forbidden = requireAdmin(context);
+ if (forbidden) return forbidden;
+ const id = context.req.param("id");
+ const body = (await context.req.json().catch(() => null)) as {
+ refs?: unknown;
+ } | null;
+ if (
+ body?.refs !== undefined &&
+ (!Array.isArray(body.refs) ||
+ body.refs.some((ref) => typeof ref !== "string"))
+ )
+ return context.json({ error: "Tools must be a list of refs." }, 400);
+ const chosen = body?.refs as string[] | undefined;
+
+ let carriedOut = false;
+ const approve = async (request: AppAccessRequestRecord) => {
+ const deny = (
+ reason: string,
+ error: string,
+ status: 400 | 404 | 409 | 502 | 503,
+ ) =>
+ refused(context, {
+ app: request.appKey,
+ request: request.id,
+ bot: request.agentId,
+ kind: request.kind,
+ reason,
+ error,
+ status,
+ });
+
+ const inspect = () =>
+ store.inspectApp({
+ app: request.appKey,
+ agentId: request.agentId ?? "",
+ userId: null,
+ composioConfigured,
+ directory,
+ });
+
+ let inspected = await inspect();
+ if (!inspected)
+ return deny(
+ "no_such_app",
+ `${request.appTitle} is no longer an app this deployment can find.`,
+ 409,
+ );
+
+ let enabled = false;
+ if (request.kind !== "connect_shared" && inspected.unavailable) {
+ const title = inspected.access.displayName;
+ if (inspected.unavailable !== "absent")
+ return deny(
+ inspected.unavailable,
+ unavailableSentence(inspected.unavailable, title, activeProvider),
+ inspected.unavailable === "composio_unconfigured" ? 503 : 409,
+ );
+ if (inspected.toolkit) {
+ if (!composio)
+ return deny(
+ "composio_unconfigured",
+ unavailableSentence(
+ "composio_unconfigured",
+ title,
+ activeProvider,
+ ),
+ 503,
+ );
+ const outcome = await enableComposioApp(
+ { store, composio, activeProvider, activeVendor },
+ inspected.toolkit,
+ actorEmail(context),
+ );
+ if (!outcome.ok)
+ return deny("enable_failed", outcome.error, outcome.status);
+ } else if (inspected.entry) {
+ const needed = adminInputFor(inspected.entry);
+ if (needed)
+ return deny(
+ "needs_admin_input",
+ `${title} needs ${needed}, which only an administrator can supply. Add it on its page under Plugins, then approve this request again.`,
+ 409,
+ );
+ try {
+ await store.addServer({
+ key: inspected.entry.key,
+ by: actorEmail(context),
+ });
+ } catch (error) {
+ if (
+ error instanceof CatalogueEntryUnknownError ||
+ error instanceof CustomServerRefusedError
+ )
+ return deny("enable_failed", error.message, 400);
+ if (isDeploymentFault(error))
+ return deny("enable_failed", deploymentFaultSentence(error), 409);
+ throw error;
+ }
+ } else {
+ return deny(
+ "absent",
+ unavailableSentence("absent", title, activeProvider),
+ 409,
+ );
+ }
+ enabled = true;
+ inspected = await inspect();
+ if (!inspected || inspected.unavailable)
+ return deny(
+ "enable_failed",
+ `${title} was switched on and still cannot be used. Open its page under Plugins to see why.`,
+ 409,
+ );
+ }
+
+ let granted: string[] = [];
+ if (
+ request.kind !== "connect_shared" &&
+ request.agentId &&
+ !inspected.access.granted
+ ) {
+ const agentId = request.agentId;
+ const serverId = inspected.answeringId;
+ const name = inspected.access.displayName;
+ if (!(await store.agentIsRegistered(agentId)) || !serverId)
+ return deny("no_such_bot", "That Bot is no longer here.", 409);
+ const tools = await store.appTools(serverId);
+ const advertised = tools.map((tool) => tool.ref);
+ const refs =
+ chosen ?? tools.filter((tool) => tool.read).map((tool) => tool.ref);
+ const outside = refs.filter((ref) => !advertised.includes(ref));
+ if (outside.length > 0)
+ return deny(
+ "refs_outside_app",
+ `${outside.join(", ")} ${outside.length === 1 ? "is" : "are"} not among ${name}'s tools.`,
+ 400,
+ );
+ if (advertised.length === 0)
+ return deny(
+ "no_tools",
+ `${name} advertises no tools to grant yet. Refresh its tools on its page under Plugins, then approve this request again.`,
+ 409,
+ );
+ if (refs.length === 0)
+ return deny(
+ "no_read_only_tools",
+ `None of ${name}'s actions is marked read-only, so approving here would grant nothing. Choose which actions this Bot may use on ${name}'s page for it under Plugins, then approve this request again.`,
+ 409,
+ );
+ for (const ref of refs) {
+ await store.grant("mcp", ref, agentId, actorEmail(context));
+ }
+ const sharedDecision = await sharedGrantApproval(
+ { store, shared },
+ refs[0] ?? "",
+ agentId,
+ undefined,
+ );
+ if (
+ sharedDecision !== "unreadable" &&
+ sharedDecision.approval &&
+ shared
+ )
+ await shared.use.setApproval({
+ botId: agentId,
+ serverId: sharedDecision.appId,
+ approval: sharedDecision.approval,
+ by: context.var.actor.id,
+ });
+ granted = refs;
+ }
+
+ if (
+ request.kind === "connect_shared" &&
+ inspected.access.account === "shared" &&
+ !inspected.sharedUsable
+ )
+ return deny(
+ "shared_not_connected",
+ `The shared ${inspected.access.displayName} account is not connected yet. Connect it on ${inspected.access.displayName}'s page under Plugins, then approve this request.`,
+ 409,
+ );
+
+ carriedOut = true;
+ const closed = request;
+ await store.recordAppAccessEvent({
+ eventType: "app_access.approved",
+ targetId: closed.appKey,
+ actorUserId: context.var.actor.id,
+ payload: {
+ request: closed.id,
+ app: closed.appKey,
+ bot: closed.agentId,
+ kind: closed.kind,
+ enabled,
+ granted: granted.length,
+ },
+ });
+ return context.json({ request: closed, enabled, granted });
+ };
+
+ const claimed = await store.claimAppAccessRequest({
+ id,
+ by: context.var.actor.id,
+ });
+ if (!claimed) {
+ const existing = await store.appAccessRequest(id);
+ return refused(context, {
+ app: existing?.appKey ?? "",
+ request: id,
+ reason: existing ? "already_resolved" : "no_such_request",
+ error: existing
+ ? "That request has already been answered."
+ : "There is no such request.",
+ status: existing ? 409 : 404,
+ });
+ }
+ try {
+ return await approve(claimed);
+ } finally {
+ if (!carriedOut)
+ await store.releaseAppAccessRequest({ id, by: context.var.actor.id });
+ }
+ });
+
+ /** Dismiss a request: nothing changes but the request, which stops waiting. */
+ routes.post("/access-requests/:id/dismiss", requireUser, async (context) => {
+ const forbidden = requireAdmin(context);
+ if (forbidden) return forbidden;
+ const id = context.req.param("id");
+ const closed: AppAccessRequestRecord | null =
+ await store.closeAppAccessRequest({
+ id,
+ status: "dismissed",
+ by: context.var.actor.id,
+ });
+ if (!closed) {
+ const existing = await store.appAccessRequest(id);
+ return refused(context, {
+ app: existing?.appKey ?? "",
+ request: id,
+ reason: existing ? "already_resolved" : "no_such_request",
+ error: existing
+ ? "That request has already been answered."
+ : "There is no such request.",
+ status: existing ? 409 : 404,
+ });
+ }
+ await store.recordAppAccessEvent({
+ eventType: "app_access.dismissed",
+ targetId: closed.appKey,
+ actorUserId: context.var.actor.id,
+ payload: {
+ request: closed.id,
+ app: closed.appKey,
+ bot: closed.agentId,
+ kind: closed.kind,
+ },
+ });
+ return context.json({ request: closed });
+ });
+}
diff --git a/server/src/plugins/route-groups/composio-apps.ts b/server/src/plugins/route-groups/composio-apps.ts
index ca7c4ead90..2636afa8b5 100644
--- a/server/src/plugins/route-groups/composio-apps.ts
+++ b/server/src/plugins/route-groups/composio-apps.ts
@@ -13,6 +13,7 @@ import {
deploymentFaultSentence,
isDeploymentFault,
PluginRefusedError,
+ type ServerRecord,
} from "../store";
import {
brokerRefusal,
@@ -158,16 +159,6 @@ export function mountComposioAppRoutes({
const forbidden = requireAdmin(context);
if (forbidden) return forbidden;
- if (!composio) {
- return context.json(
- {
- error: new BrokerUnconfiguredError(activeProvider, "operator")
- .message,
- },
- 503,
- );
- }
-
const body = (await context.req.json().catch(() => null)) as {
slug?: unknown;
} | null;
@@ -183,177 +174,224 @@ export function mountComposioAppRoutes({
* about that, so the narrowing answers with `undefined` rather than a second sentence.
*/
const slug = typeof body?.slug === "string" ? body.slug.trim() : undefined;
- let directory: BrokerApp[] = [];
- if (slug) {
- try {
- directory = await withVendorDeadline(() => composio.broker.listApps());
- } catch (error) {
- /*
- * The same failure the directory route answers, in the same words, because it is the same
- * call. Unhandled it was the worst 500 of the three: an administrator pressing Add on a
- * deployment whose key is wrong was told nothing at all, on the one screen where the key
- * had just been set.
- */
- const refusal = brokerRefusal(
- error,
- directoryUnavailable(activeProvider),
- );
- return context.json({ error: refusal.error }, refusal.status);
- }
- }
- const app = directory.find((candidate) => candidate.slug === slug);
- if (!app) {
- return context.json(
- {
- error: slug
- ? `${slug} is not an app ${BROKERED_PROFILE[activeProvider].vendor} lists for this deployment.`
- : "An app is required.",
- },
- 400,
- );
+ const outcome = await enableComposioApp(
+ { store, composio, activeProvider, activeVendor },
+ slug,
+ actorEmail(context),
+ );
+ return outcome.ok
+ ? context.json({ server: outcome.server }, 201)
+ : context.json({ error: outcome.error }, outcome.status);
+ });
+}
+
+/** What enabling one Composio app answered: the row it wrote, or the refusal and its status. */
+export type EnableOutcome =
+ | { ok: true; server: ServerRecord }
+ | { ok: false; error: string; status: 400 | 409 | 502 | 503 };
+
+/**
+ * Enable one app of Composio's catalogue, by slug: the body of `POST /composio/apps`, and what
+ * approving a person's request to switch an app on runs.
+ *
+ * ONE FUNCTION FOR BOTH DOORS, so an app switched on from the request inbox is checked against
+ * the live directory, refused when this deployment cannot drive it, and mapped to the same
+ * sentences as one switched on from the picker. The caller decides who may call it; both callers
+ * are administrator-only.
+ */
+export async function enableComposioApp(
+ {
+ store,
+ composio,
+ activeProvider,
+ activeVendor,
+ }: Pick<
+ PluginRouteContext,
+ "store" | "composio" | "activeProvider" | "activeVendor"
+ >,
+ slug: string | undefined,
+ by: string,
+): Promise {
+ if (!composio) {
+ return {
+ ok: false,
+ error: new BrokerUnconfiguredError(activeProvider, "operator").message,
+ status: 503,
+ };
+ }
+ let directory: BrokerApp[] = [];
+ if (slug) {
+ try {
+ directory = await withVendorDeadline(() => composio.broker.listApps());
+ } catch (error) {
+ /*
+ * The same failure the directory route answers, in the same words, because it is the same
+ * call. Unhandled it was the worst 500 of the three: an administrator pressing Add on a
+ * deployment whose key is wrong was told nothing at all, on the one screen where the key
+ * had just been set.
+ */
+ return {
+ ok: false,
+ ...brokerRefusal(error, directoryUnavailable(activeProvider)),
+ };
}
+ }
+ const app = directory.find((candidate) => candidate.slug === slug);
+ if (!app) {
+ return {
+ ok: false,
+ error: slug
+ ? `${slug} is not an app ${BROKERED_PROFILE[activeProvider].vendor} lists for this deployment.`
+ : "An app is required.",
+ status: 400,
+ };
+ }
+
+ /*
+ * AN APP THIS DEPLOYMENT CANNOT CONNECT IS NOT AN APP TO ADD, and the GET half above is not
+ * enough on its own: it hides every `unsupported` app from the picker, so nobody presses Add on
+ * one, but this route validates against the unfiltered `directory` — so a request that names
+ * one by hand arrives here past that filter.
+ *
+ * WHAT IT WOULD REACH IS A STORE METHOD THAT RECORDS A MISLEADING ROW. `schemeFor` writes
+ * `null` on `auth_scheme` for an unsupported connection, and a null there is read everywhere
+ * else as "not a brokered row at all" — so the row enabling would write is one whose recorded
+ * kind contradicts what it is.
+ *
+ * A SECOND GUARD RATHER THAN A REPLACEMENT: the adapter keeps its own refusal, and the two
+ * catch different things. This one stops a CALLER OF THIS ROUTE reaching a store method that
+ * would write that row — which is a fact about `addBrokeredApp`, and holds for whatever broker
+ * is behind it, including one that would happily create the config. The adapter's stops ANY
+ * caller at all — this route, a script, a future path — from reaching the vendor for an app
+ * this deployment has nowhere to hold an OAuth client for. Neither subsumes the other, and the
+ * store itself still has no guard, which is precisely why the cheap one here is worth having.
+ *
+ * The derivation's own `reason` is the sentence, because it names what is missing for this app
+ * rather than for unsupported apps in general. 503 because this is a refusal this deployment
+ * authored — the same status `brokerRefusal` gives an authored refusal raised a layer down, and
+ * for the same reading: the brokered surface is unavailable for this app until somebody here
+ * changes something, rather than unavailable because a third party is down. Not the 400 above,
+ * which says the app does not exist; this one does exist, and this deployment cannot drive it.
+ */
+ if (app.connection.kind === "unsupported") {
+ return { ok: false, error: app.connection.reason, status: 503 };
+ }
+ try {
+ const server = await store.addBrokeredApp({
+ slug: app.slug,
+ title: app.name,
+ logo: app.logo,
+ by,
+ // Off the directory entry the administrator chose, never derived a second time here: a
+ // second derivation is a second answer, which is the one thing `BrokerConnection` exists to
+ // prevent. It decides what config the store creates at the vendor, and what the row records.
+ connection: app.connection,
+ });
+ return { ok: true, server };
+ } catch (error) {
+ // The same mapping the add routes above make, for the same reason: a refusal an
+ // administrator can correct comes back as itself rather than as a 500.
+ if (
+ error instanceof CustomServerRefusedError ||
+ error instanceof PluginRefusedError
+ ) {
+ return { ok: false, error: error.message, status: 400 };
+ }
+ // And, as on those routes, enabling refreshes before it answers, so every fault
+ // `refreshTools` raises arrives here as well. Its sentence blames nobody and names the row,
+ // which is the honest answer whichever side of the insert the fault arrived on.
+ if (isDeploymentFault(error)) {
+ return {
+ ok: false,
+ error: deploymentFaultSentence(error),
+ status: 409,
+ };
+ }
/*
- * AN APP THIS DEPLOYMENT CANNOT CONNECT IS NOT AN APP TO ADD, and the GET half above is not
- * enough on its own: it hides every `unsupported` app from the picker, so nobody presses Add on
- * one, but this route validates against the unfiltered `directory` — so a request that names
- * one by hand arrives here past that filter.
+ * THE ONE REFUSAL THIS ROUTE CAN ONLY MEET AFTER THE ROW IS COMMITTED, and the only one its
+ * three sibling add routes map and it did not.
*
- * WHAT IT WOULD REACH IS A STORE METHOD THAT RECORDS A MISLEADING ROW. `schemeFor` writes
- * `null` on `auth_scheme` for an unsupported connection, and a null there is read everywhere
- * else as "not a brokered row at all" — so the row enabling would write is one whose recorded
- * kind contradicts what it is.
+ * CRITERION. A failure thrown after `addBrokeredApp` has written and audited the row is not
+ * reported as an app that was not enabled, and is not reported as Composio's doing.
*
- * A SECOND GUARD RATHER THAN A REPLACEMENT: the adapter keeps its own refusal, and the two
- * catch different things. This one stops a CALLER OF THIS ROUTE reaching a store method that
- * would write that row — which is a fact about `addBrokeredApp`, and holds for whatever broker
- * is behind it, including one that would happily create the config. The adapter's stops ANY
- * caller at all — this route, a script, a future path — from reaching the vendor for an app
- * this deployment has nowhere to hold an OAuth client for. Neither subsumes the other, and the
- * store itself still has no guard, which is precisely why the cheap one here is worth having.
+ * WHERE IT ARRIVES FROM. `addBrokeredApp` creates the auth config, inserts the row and files
+ * its `configuration.changed` entry, and only THEN refreshes and reads the row back out of
+ * `listServers` — raising this when it is not there, as `requireServer` does one call
+ * deeper. Every one of those steps has already committed by then.
*
- * The derivation's own `reason` is the sentence, because it names what is missing for this app
- * rather than for unsupported apps in general. 503 because this is a refusal this deployment
- * authored — the same status `brokerRefusal` gives an authored refusal raised a layer down, and
- * for the same reading: the brokered surface is unavailable for this app until somebody here
- * changes something, rather than unavailable because a third party is down. Not the 400 above,
- * which says the app does not exist; this one does exist, and this deployment cannot drive it.
+ * WHAT IT USED TO BE ANSWERED WITH. Nothing caught it, so it fell through to the broker tail
+ * below and an administrator was told "Slack could not be enabled, and Composio said nothing
+ * about why. Try again, and check this deployment's Composio key if it persists." That is
+ * wrong three times over: the app WAS enabled, Composio had no part in the step that failed,
+ * and the one thing the reader now holds — a row on their Plugins page — went unmentioned
+ * while they were sent to check a key that is fine.
+ *
+ * 409 RATHER THAN THE 400 ITS SIBLINGS GIVE THIS CLASS, because it is not the same fact
+ * wearing the same class. On the add routes above, an unknown key is a caller naming a
+ * server this deployment will not connect to, which is a malformed request. Here the row was
+ * written and this deployment cannot see it: two of its own reads disagree, and the request
+ * cannot be answered until one of them changes — the reading the refresh route's own 409
+ * spells out. The message is written here rather than passed through, because
+ * {@link CatalogueEntryUnknownError}'s own sentence is the one for the other case.
*/
- if (app.connection.kind === "unsupported") {
- return context.json({ error: app.connection.reason }, 503);
+ if (error instanceof CatalogueEntryUnknownError) {
+ return {
+ ok: false,
+ error: `${app.name} was added, and then could not be read back out of this deployment's own servers — so the row and its trail entry stand, and what is on this page may be missing it. Reload the Plugins page: if ${app.name} is there it is enabled and there is nothing to redo, and if it is not, adding it again is safe.`,
+ status: 409,
+ };
+ }
+ /*
+ * THE SAME MAPPING THE DIRECTORY READ ABOVE MAKES, FOR THE SAME REASON, and its absence here
+ * is what left an administrator with nothing. Composio's own sentence — "Default auth config
+ * not found for toolkit linear_mcp. Composio does not have managed credentials for this
+ * toolkit." — travelled as an unhandled throw, so the route answered a bodyless 500 and the
+ * browser fell back to its own "That app could not be added". Everything that explains the
+ * failure existed; nothing carried it the last step.
+ */
+ /*
+ * WHAT THE MAPPING COST, PUT BACK. A throw used to reach Hono's default handler, which is a
+ * bad answer and a good log: the administrator got a bodyless 500, but the stack was printed
+ * where an operator could find it. Catching everything fixed the answer and silenced the log,
+ * and the failure that needs the log most is the one left here — not a refusal this
+ * deployment authored, which `brokerSentence` names and `isDeploymentFault` already took one
+ * branch above, and not a sentence Composio wrote, which `vendorSentence` reaches for. Both
+ * null is a fault nobody has explained, most often a programmer error on this side, and it
+ * would otherwise leave only the generic "Composio said nothing about why" in a browser
+ * nobody is reading a console from.
+ *
+ * The same shape and the same restraint as the connection-not-recorded log below: the slug,
+ * because it is the app an operator is about to be asked about; what the person was told, so
+ * the console line and the support request can be matched up; and the error read through
+ * {@link reasonWithoutStatement}, never spread, logged as an object or reached into. No key,
+ * no vendor response, no request body, and no statement — the two sentence tests below do not
+ * exclude a query failure, they are the branch one arrives in.
+ *
+ * AND THE NAME ON IT IS TRUE OF EVERY FAILURE THAT STILL REACHES IT. The two branches above
+ * take the whole of what `addBrokeredApp` can raise once the row is committed — the shelf,
+ * and the read-back that could not find it — so what is left arrives from the steps before
+ * the insert, where nothing was written and the app really was not enabled. The generic
+ * sentence below names Composio for the same reason: the one call in front of the insert is
+ * `ensureAuthConfig`, which is a call to the vendor.
+ */
+ if (brokerSentence(error) === null && vendorSentence(error) === null) {
+ console.error(
+ JSON.stringify({
+ type: "composio-app-not-enabled",
+ slug: app.slug,
+ note: `Enabling a brokered app failed for a reason neither this deployment nor ${activeVendor} put a sentence to. The administrator was answered 502 with the generic sentence.`,
+ error: reasonWithoutStatement(error),
+ }),
+ );
}
- try {
- const server = await store.addBrokeredApp({
- slug: app.slug,
- title: app.name,
- logo: app.logo,
- by: actorEmail(context),
- // Off the directory entry the administrator chose, never derived a second time here: a
- // second derivation is a second answer, which is the one thing `BrokerConnection` exists to
- // prevent. It decides what config the store creates at the vendor, and what the row records.
- connection: app.connection,
- });
- return context.json({ server }, 201);
- } catch (error) {
- // The same mapping the add routes above make, for the same reason: a refusal an
- // administrator can correct comes back as itself rather than as a 500.
- if (
- error instanceof CustomServerRefusedError ||
- error instanceof PluginRefusedError
- ) {
- return context.json({ error: error.message }, 400);
- }
- // And, as on those routes, enabling refreshes before it answers, so every fault
- // `refreshTools` raises arrives here as well. Its sentence blames nobody and names the row,
- // which is the honest answer whichever side of the insert the fault arrived on.
- if (isDeploymentFault(error)) {
- return context.json({ error: deploymentFaultSentence(error) }, 409);
- }
- /*
- * THE ONE REFUSAL THIS ROUTE CAN ONLY MEET AFTER THE ROW IS COMMITTED, and the only one its
- * three sibling add routes map and it did not.
- *
- * CRITERION. A failure thrown after `addBrokeredApp` has written and audited the row is not
- * reported as an app that was not enabled, and is not reported as Composio's doing.
- *
- * WHERE IT ARRIVES FROM. `addBrokeredApp` creates the auth config, inserts the row and files
- * its `configuration.changed` entry, and only THEN refreshes and reads the row back out of
- * `listServers` — raising this when it is not there, as `requireServer` does one call
- * deeper. Every one of those steps has already committed by then.
- *
- * WHAT IT USED TO BE ANSWERED WITH. Nothing caught it, so it fell through to the broker tail
- * below and an administrator was told "Slack could not be enabled, and Composio said nothing
- * about why. Try again, and check this deployment's Composio key if it persists." That is
- * wrong three times over: the app WAS enabled, Composio had no part in the step that failed,
- * and the one thing the reader now holds — a row on their Plugins page — went unmentioned
- * while they were sent to check a key that is fine.
- *
- * 409 RATHER THAN THE 400 ITS SIBLINGS GIVE THIS CLASS, because it is not the same fact
- * wearing the same class. On the add routes above, an unknown key is a caller naming a
- * server this deployment will not connect to, which is a malformed request. Here the row was
- * written and this deployment cannot see it: two of its own reads disagree, and the request
- * cannot be answered until one of them changes — the reading the refresh route's own 409
- * spells out. The message is written here rather than passed through, because
- * {@link CatalogueEntryUnknownError}'s own sentence is the one for the other case.
- */
- if (error instanceof CatalogueEntryUnknownError) {
- return context.json(
- {
- error: `${app.name} was added, and then could not be read back out of this deployment's own servers — so the row and its trail entry stand, and what is on this page may be missing it. Reload the Plugins page: if ${app.name} is there it is enabled and there is nothing to redo, and if it is not, adding it again is safe.`,
- },
- 409,
- );
- }
- /*
- * THE SAME MAPPING THE DIRECTORY READ ABOVE MAKES, FOR THE SAME REASON, and its absence here
- * is what left an administrator with nothing. Composio's own sentence — "Default auth config
- * not found for toolkit linear_mcp. Composio does not have managed credentials for this
- * toolkit." — travelled as an unhandled throw, so the route answered a bodyless 500 and the
- * browser fell back to its own "That app could not be added". Everything that explains the
- * failure existed; nothing carried it the last step.
- */
- /*
- * WHAT THE MAPPING COST, PUT BACK. A throw used to reach Hono's default handler, which is a
- * bad answer and a good log: the administrator got a bodyless 500, but the stack was printed
- * where an operator could find it. Catching everything fixed the answer and silenced the log,
- * and the failure that needs the log most is the one left here — not a refusal this
- * deployment authored, which `brokerSentence` names and `isDeploymentFault` already took one
- * branch above, and not a sentence Composio wrote, which `vendorSentence` reaches for. Both
- * null is a fault nobody has explained, most often a programmer error on this side, and it
- * would otherwise leave only the generic "Composio said nothing about why" in a browser
- * nobody is reading a console from.
- *
- * The same shape and the same restraint as the connection-not-recorded log below: the slug,
- * because it is the app an operator is about to be asked about; what the person was told, so
- * the console line and the support request can be matched up; and the error read through
- * {@link reasonWithoutStatement}, never spread, logged as an object or reached into. No key,
- * no vendor response, no request body, and no statement — the two sentence tests below do not
- * exclude a query failure, they are the branch one arrives in.
- *
- * AND THE NAME ON IT IS TRUE OF EVERY FAILURE THAT STILL REACHES IT. The two branches above
- * take the whole of what `addBrokeredApp` can raise once the row is committed — the shelf,
- * and the read-back that could not find it — so what is left arrives from the steps before
- * the insert, where nothing was written and the app really was not enabled. The generic
- * sentence below names Composio for the same reason: the one call in front of the insert is
- * `ensureAuthConfig`, which is a call to the vendor.
- */
- if (brokerSentence(error) === null && vendorSentence(error) === null) {
- console.error(
- JSON.stringify({
- type: "composio-app-not-enabled",
- slug: app.slug,
- note: `Enabling a brokered app failed for a reason neither this deployment nor ${activeVendor} put a sentence to. The administrator was answered 502 with the generic sentence.`,
- error: reasonWithoutStatement(error),
- }),
- );
- }
-
- const refusal = brokerRefusal(
+ return {
+ ok: false,
+ ...brokerRefusal(
error,
`${app.name} could not be enabled, and ${activeVendor} said nothing about why. Try again, and check this deployment's ${activeVendor} key if it persists.`,
- );
- return context.json({ error: refusal.error }, refusal.status);
- }
- });
+ ),
+ };
+ }
}
diff --git a/server/src/plugins/route-groups/grants.ts b/server/src/plugins/route-groups/grants.ts
index dd1b0cb3a6..5000301b88 100644
--- a/server/src/plugins/route-groups/grants.ts
+++ b/server/src/plugins/route-groups/grants.ts
@@ -13,6 +13,46 @@ import {
} from "../store";
import type { PluginRouteContext } from "./context";
+/**
+ * What a grant of one MCP tool must record beside it: the approval for who may use a Shared app's
+ * one account through this Bot.
+ *
+ * Without an approval the gate refuses every call the grant just allowed, so a grant with none sent
+ * approves the Bot's exposure as it stands now — unless an approval is already recorded, which is
+ * an administrator's earlier decision and not this grant's to widen. "Is Shared" is the app's
+ * answer (its answering row), never the granted row's own column. Null `approval` is nothing to
+ * record; `unreadable` is an approval sent that this deployment cannot read, which the caller
+ * refuses.
+ *
+ * A FUNCTION RATHER THAN LINES IN THE ROUTE because approving an access request grants through the
+ * same rule, and a Shared app granted from the request inbox must land with the same approval it
+ * would have had from the grant switch.
+ */
+export async function sharedGrantApproval(
+ { store, shared }: Pick,
+ ref: string,
+ agentId: string,
+ sent: unknown,
+): Promise<
+ { appId: string; approval: SharedUseApproval | null } | "unreadable"
+> {
+ if (!shared) return { appId: "", approval: null };
+ const [serverId] = ref.split("/");
+ const appId = await shared.use.appIdOf(serverId ?? "");
+ if ((await store.serverAddress(appId))?.accountMode !== "shared")
+ return { appId, approval: null };
+ if (sent !== undefined) {
+ const approval = asSharedUseApproval(sent);
+ return approval ? { appId, approval } : "unreadable";
+ }
+ if (await shared.use.approvalFor(agentId, appId))
+ return { appId, approval: null };
+ return {
+ appId,
+ approval: exposureOf(await shared.use.botFacts(agentId)),
+ };
+}
+
/** Granting and revoking a server, skill or Bot, and a Bot's tool calls back. */
export function mountGrantRoutes({
routes,
@@ -235,37 +275,30 @@ export function mountGrantRoutes({
if (refusal) return context.json({ error: refusal }, 403);
/*
- * A SHARED APP'S ACTION IS GRANTED WITH WHO MAY USE IT, in the one request. Without an approval
- * the gate refuses every call the grant just allowed, so a grant with none sent approves the
- * Bot's exposure as it stands now — unless an approval is already recorded, which is an
- * administrator's earlier decision and not this request's to widen. "Is Shared" is the app's
- * answer (its answering row), never the granted row's own column.
+ * A SHARED APP'S ACTION IS GRANTED WITH WHO MAY USE IT, in the one request. See
+ * {@link sharedGrantApproval} for what is recorded when none is sent.
*/
- let approval: SharedUseApproval | null = null;
- let appId = "";
- if (kind === "mcp" && shared) {
- const [serverId] = grantRef.split("/");
- appId = await shared.use.appIdOf(serverId ?? "");
- if ((await store.serverAddress(appId))?.accountMode === "shared") {
- if (body?.approval !== undefined) {
- approval = asSharedUseApproval(body.approval);
- if (!approval) {
- return context.json(
- { error: "That approval is not one this deployment can read." },
- 400,
- );
- }
- } else if (!(await shared.use.approvalFor(grantAgentId, appId))) {
- approval = exposureOf(await shared.use.botFacts(grantAgentId));
- }
- }
+ const decided =
+ kind === "mcp"
+ ? await sharedGrantApproval(
+ { store, shared },
+ grantRef,
+ grantAgentId,
+ body?.approval,
+ )
+ : null;
+ if (decided === "unreadable") {
+ return context.json(
+ { error: "That approval is not one this deployment can read." },
+ 400,
+ );
}
await store.grant(kind, grantRef, grantAgentId, actorEmail(context));
- if (approval && shared) {
+ if (decided?.approval && shared) {
await shared.use.setApproval({
botId: grantAgentId,
- serverId: appId,
- approval,
+ serverId: decided.appId,
+ approval: decided.approval,
by: context.var.actor.id,
});
}
diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts
index e78ce8b17a..f3032f8b03 100644
--- a/server/src/plugins/routes.ts
+++ b/server/src/plugins/routes.ts
@@ -3,6 +3,7 @@ import type { BotAccessCheck } from "../agents/profile-policy";
import type { AppVariables } from "../auth/guards";
import { BROKERED_PROFILE, type BrokerProvider, toolkitOfFor } from "./access";
import type { AccountModeSwitch } from "./account-mode";
+import { mountAppAccessRoutes } from "./route-groups/app-access";
import type { ComposioBroker } from "./broker";
import { mountComposioAppRoutes } from "./route-groups/composio-apps";
import { mountConnectionRoutes } from "./route-groups/connections";
@@ -213,6 +214,7 @@ export function createPluginRoutes(
mountOAuthCallbackRoute(context);
mountSkillRoutes(context);
mountGrantRoutes(context);
+ mountAppAccessRoutes(context);
return routes;
}
diff --git a/server/src/plugins/shared-use-store.ts b/server/src/plugins/shared-use-store.ts
index 1ac9f96096..58a071655f 100644
--- a/server/src/plugins/shared-use-store.ts
+++ b/server/src/plugins/shared-use-store.ts
@@ -676,9 +676,34 @@ export function createSharedUseStore(
return row;
}
+ /**
+ * Whether the gate would let this person's own chat with this Bot reach the app's team account —
+ * the same approval, owner and audience test the gate makes for a person-started run, and nothing
+ * it does after refusing. For a screen saying what a call would meet; the gate still decides
+ * every call.
+ */
+ async function admitsPerson(input: {
+ botId: string;
+ serverId: string;
+ actorId: string;
+ }): Promise {
+ const approval = await approvalFor(input.botId, input.serverId);
+ if (!approval) return false;
+ const facts = await botFacts(input.botId);
+ return admits(
+ approval,
+ facts.ownerUserId,
+ await actorFacts(input.actorId),
+ {
+ outside: false,
+ },
+ );
+ }
+
return {
appIdOf,
approvalFor,
+ admitsPerson,
setApproval,
deleteApprovalsFor,
botFacts,
diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts
index e74eb9e77e..b06db612ca 100644
--- a/server/src/plugins/store.ts
+++ b/server/src/plugins/store.ts
@@ -1,3 +1,4 @@
+import { appAccessMethods } from "./plugin-store/app-access";
import { callMethods } from "./plugin-store/calls";
import { connectionMethods } from "./plugin-store/connections";
import { createStoreContext } from "./plugin-store/context";
@@ -52,6 +53,7 @@ export function createPluginStore(options: PluginStoreOptions) {
...grantMethods(context),
...connectionMethods(context),
...callMethods(context),
+ ...appAccessMethods(context),
};
}
diff --git a/server/src/plugins/tools.ts b/server/src/plugins/tools.ts
index baa73f39af..9496f2f6a9 100644
--- a/server/src/plugins/tools.ts
+++ b/server/src/plugins/tools.ts
@@ -8,6 +8,10 @@ import {
catalogueEntry,
researchRoleFor,
} from "./catalogue";
+import {
+ addressedTo,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
import { type ResearchRole, RESEARCH_TOOLS } from "@openbot/shared/research";
import type { SelectableSkill } from "./selection";
import {
@@ -100,10 +104,19 @@ export function grantedToolGuidance(
*/
connectedButNotHeld: readonly string[] = [],
): string {
- if (tools.length === 0 && connectedButNotHeld.length === 0) return "";
+ /*
+ * THE CARD TOOL IS NOT A SYSTEM. It is offered to every Bot so a missing app can be put in front of
+ * the person with the step that fixes it, and it reaches nothing — listed beside the vendors it
+ * would read as one more system this Bot "can reach directly, as the person asking". It is named
+ * below instead, at exactly the sentences that used to end on "an administrator can grant it".
+ */
+ const showsCards = tools.some((tool) => tool.name === SHOW_APP_ACCESS_TOOL);
+ const reaching = tools.filter((tool) => tool.name !== SHOW_APP_ACCESS_TOOL);
+ if (reaching.length === 0 && connectedButNotHeld.length === 0 && !showsCards)
+ return "";
const bySystem = new Map();
- for (const tool of tools) {
+ for (const tool of reaching) {
// `mcp__server__tool`, which is the shape the model is offered.
const parts = tool.name.replace(/^mcp__/, "").split("__");
const system = parts.length > 1 ? (parts[0] as string) : "this deployment";
@@ -116,10 +129,10 @@ export function grantedToolGuidance(
(system) => !held.includes(system),
);
- const research = researchProviders(tools);
+ const research = researchProviders(reaching);
return [
- ...(tools.length > 0
+ ...(reaching.length > 0
? [
"You can reach these systems directly, as the person asking, with their own access:",
]
@@ -127,7 +140,7 @@ export function grantedToolGuidance(
...[...bySystem.entries()].map(
([system, names]) => `- ${system}: ${names.join(", ")}`,
),
- ...(tools.length > 0
+ ...(reaching.length > 0
? [
"Use them for anything about those systems. Do NOT browse to one of their websites instead: your",
"browser is signed in as nobody, so it sees less than these tools do and will meet a sign-in wall",
@@ -151,12 +164,35 @@ export function grantedToolGuidance(
*/
...(missing.length > 0
? [
- ...(tools.length > 0 ? [""] : []),
+ ...(reaching.length > 0 ? [""] : []),
`This deployment also connects to: ${missing.join(", ")}. You hold none of their tools.`,
- "If a question needs one of them, say plainly that you have not been granted it and that an",
- "administrator can grant it on that connector. Do NOT browse to its website: that is not the",
- "same thing, your browser is signed in as nobody, and it will meet a sign-in wall that the",
- "connector exists to avoid. Do not ask the person to sign in there either.",
+ ...(showsCards
+ ? [
+ `If a question needs one of them, call ${SHOW_APP_ACCESS_TOOL} with its name: that puts a card in`,
+ "front of the person saying you have not been granted it, with a button to ask an administrator",
+ "for it. Then say so in a sentence; the card already carries the rest.",
+ ]
+ : [
+ "If a question needs one of them, say plainly that you have not been granted it and that an",
+ "administrator can grant it on that connector.",
+ ]),
+ "Do NOT browse to its website: that is not the same thing, your browser is signed in as nobody,",
+ "and it will meet a sign-in wall that the connector exists to avoid. Do not ask the person to",
+ "sign in there either.",
+ ]
+ : []),
+ /*
+ * AN APP THIS DEPLOYMENT DOES NOT CONNECT AT ALL is the one case nothing above names, and the
+ * one a Bot met most bluntly: asked about Gmail on a deployment that has not switched it on, it
+ * had no word for Gmail anywhere in its instructions and either browsed or said it could not.
+ * The card says whether it can be switched on and lets the person ask for it.
+ */
+ ...(showsCards
+ ? [
+ ...(reaching.length > 0 || missing.length > 0 ? [""] : []),
+ `If the person needs a work app that is not named here at all (Gmail, Slack, Salesforce), call`,
+ `${SHOW_APP_ACCESS_TOOL} with its name before saying you cannot reach it: the card tells them whether it`,
+ "can be switched on and lets them ask an administrator. Do not browse to its website instead.",
]
: []),
].join("\n");
@@ -281,7 +317,12 @@ export async function grantedTools(options: {
error instanceof PluginRefusedError ||
error instanceof ApprovalRefusedError
) {
- return `${REFUSAL_MARKER} ${error.message}`;
+ /*
+ * A refusal that carries an app card names the Bot here, the one place both are known:
+ * in a channel several Bots answer in, the card has to ask what the app is for this
+ * person on THIS Bot, not on whichever one the surface happens to have declared.
+ */
+ return `${REFUSAL_MARKER} ${addressedTo(error.message, botId)}`;
}
/*
* A contradiction in this deployment's own tables says nothing to a model.
diff --git a/server/tests/app-access-routes.integration.test.ts b/server/tests/app-access-routes.integration.test.ts
new file mode 100644
index 0000000000..1fccc556c9
--- /dev/null
+++ b/server/tests/app-access-routes.integration.test.ts
@@ -0,0 +1,1551 @@
+// server/tests/app-access-routes.integration.test.ts
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ test,
+} from "bun:test";
+import { randomUUID } from "node:crypto";
+import { and, eq, inArray, like } from "drizzle-orm";
+import { Hono, type MiddlewareHandler } from "hono";
+import type { AuditEventInput, AuditStore } from "../src/audit";
+import type { AppVariables } from "../src/auth/guards";
+import { createDatabase } from "../src/db/client";
+import {
+ agentProfiles,
+ agents,
+ appAccessRequests,
+ brokeredConnections,
+ credentials,
+ mcpServers,
+ mcpTools,
+ mcpUserCredentials,
+ pluginGrants,
+ sharedUseApprovals,
+ teamBotConsents,
+ teamBotPublications,
+ users,
+} from "../src/db/schema";
+import type { AppAccess } from "../src/plugins/app-access";
+import type { BrokerApp } from "../src/plugins/broker";
+import type { AppAccessRequestRecord } from "../src/plugins/plugin-store/app-access";
+import { MAX_PENDING_REQUESTS } from "../src/plugins/route-groups/app-access";
+import { createPluginRoutes } from "../src/plugins/routes";
+import { exposureOf } from "../src/plugins/shared-use";
+import { createSharedUseStore } from "../src/plugins/shared-use-store";
+import {
+ createPluginStore,
+ type PluginStore,
+ type ServerRecord,
+} from "../src/plugins/store";
+import { TEST_POOL, testDatabaseUrl } from "./support/database";
+import { stubBroker } from "./support/stub-broker";
+
+/**
+ * What a work app is for one person on one Bot, the requests they file when only an administrator
+ * can change it, and what approving one does — against the real tables, through the real routes.
+ *
+ * The vendor is the one thing faked. `addBrokeredApp` refreshes an app's tools from Composio
+ * itself, which no test can reach, so the store's enable is replaced with one that writes the row
+ * and a tool the way the real one ends; everything around it — the directory check, the grant, the
+ * Shared approval, the conditional close, the trail — is the shipped code.
+ */
+
+const database = createDatabase(testDatabaseUrl(), TEST_POOL);
+const suite = randomUUID().slice(0, 8);
+const id = (name: string) => `${name}-${suite}`;
+
+const person = id("person");
+const other = id("other");
+const admin = id("admin");
+const bot = id("bot");
+const hidden = id("hidden");
+
+/** A Composio slug for this suite, and the row id `addBrokeredApp` would give it. */
+const slug = (name: string) => `${name}_${suite}`;
+const row = (name: string) => `composio-${slug(name)}`;
+
+const GMAIL = row("gmail");
+const SLACK = row("slack");
+const LINEAR = row("linear");
+const NEWS = row("news");
+const CALENDAR = row("calendar");
+const GONE = row("gone");
+const DROPBOX = row("dropbox");
+
+const events: AuditEventInput[] = [];
+const audit: AuditStore = {
+ insert: async (event) => {
+ events.push(event);
+ },
+};
+const eventsOf = (type: string) =>
+ events.filter((event) => event.eventType === type);
+
+const directory: BrokerApp[] = [
+ {
+ slug: slug("dropbox"),
+ name: "Dropbox",
+ description: "Files",
+ logo: null,
+ categories: [],
+ actionCount: 1,
+ classifiedCount: null,
+ permissionCount: null,
+ scopedActionCount: null,
+ connection: { kind: "consent" },
+ },
+ {
+ slug: slug("gone"),
+ name: "Gone App",
+ description: "",
+ logo: null,
+ categories: [],
+ actionCount: 1,
+ classifiedCount: null,
+ permissionCount: null,
+ scopedActionCount: null,
+ connection: { kind: "consent" },
+ },
+];
+
+/**
+ * The broker, built by `stubBroker`: every member this file does not provide THROWS AND RECORDS,
+ * and the check after each test reads the record, so a path that reaches past what is provided is
+ * a red even where production catches the throw. Nothing here undoes one account, so
+ * `revokeAccount` is not provided rather than stubbed to answer "done" for a revoke that never ran.
+ */
+const stub = stubBroker({
+ // Composio's own declarations: the grant is held here, and there is no authorization flow.
+ grantIsLocal: true,
+ authorizations: null,
+ listApps: async () => directory,
+ ensureAuthConfig: async () => "created",
+ deleteAuthConfig: async () => {},
+ authorize: async () => ({
+ redirectUrl: "https://vendor.example/consent",
+ authorizationId: null,
+ }),
+ isConnected: async () => true,
+ revoke: async () => true,
+ connectionFields: async () => [],
+ connectWithFields: async () => ({ accountId: "ca_1" }),
+ accountName: async () => "team",
+});
+const broker = stub.broker;
+afterEach(() => {
+ stub.expectNothingUnprovidedReached();
+});
+
+const enabled: string[] = [];
+
+/**
+ * Run before every grant the routes make, when set — how a test holds an approval partway through
+ * its change while something else answers the same request.
+ */
+let beforeGrant: (() => Promise) | null = null;
+
+function storeFor(db = database): PluginStore {
+ const real = createPluginStore({
+ brokerProvider: "composio",
+ database: db,
+ auditStore: audit,
+ credentials: {} as never,
+ encryptionKey: "x".repeat(44),
+ policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }),
+ broker,
+ deploymentId: `acme-${suite}`,
+ });
+ return {
+ ...real,
+ async grant(...args) {
+ await beforeGrant?.();
+ return real.grant(...args);
+ },
+ async addBrokeredApp(input) {
+ enabled.push(input.slug);
+ await database.insert(mcpServers).values({
+ id: `composio-${input.slug}`,
+ title: input.title,
+ vendor: "Composio",
+ url: `composio://${input.slug}`,
+ provenance: "composio",
+ authScheme: "OAUTH2",
+ accountMode: "personal",
+ });
+ await database.insert(mcpTools).values({
+ serverId: `composio-${input.slug}`,
+ name: "LIST_FILES",
+ description: "List files",
+ inputSchema: {},
+ effect: "read",
+ });
+ return { id: `composio-${input.slug}` } as ServerRecord;
+ },
+ };
+}
+
+function appAs(
+ actorId: string,
+ role: "admin" | "user" = "user",
+ options: { composio?: boolean; database?: typeof database } = {},
+) {
+ const store = storeFor(options.database);
+ const signedIn: MiddlewareHandler<{ Variables: AppVariables }> = async (
+ context,
+ next,
+ ) => {
+ context.set("actor", {
+ id: actorId,
+ email: `${actorId}@example.test`,
+ role,
+ } as never);
+ await next();
+ };
+ const sharedUse = createSharedUseStore(
+ options.database ?? database,
+ "composio",
+ );
+ return new Hono().route(
+ "/api/plugins",
+ createPluginRoutes(
+ store,
+ signedIn,
+ // Everybody may use the suite's Bot; nobody but an administrator may use the hidden one.
+ async (actor, agentId) => agentId !== hidden || actor.role === "admin",
+ undefined,
+ options.composio === false ? undefined : { broker, provider: "composio" },
+ { modes: {} as never, use: sharedUse },
+ ),
+ );
+}
+
+async function json(response: Response): Promise {
+ return (await response.json()) as T;
+}
+
+const post = (body: unknown) => ({
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+});
+
+async function seedApp(input: {
+ key: string;
+ title: string;
+ toolkit?: string;
+ authScheme?: string | null;
+ accountMode?: "personal" | "shared" | null;
+ tools?: string[];
+ /** Each tool's `effect`, by name; a tool left out is unclassified, which reads as a write. */
+ effects?: Record;
+ destructive?: string[];
+ grant?: boolean;
+}) {
+ await database.insert(mcpServers).values({
+ id: input.key,
+ title: input.title,
+ vendor: "Composio",
+ url: `composio://${input.toolkit ?? input.key.replace(/^composio-/, "")}`,
+ provenance: "composio",
+ authScheme: input.authScheme === undefined ? "OAUTH2" : input.authScheme,
+ accountMode:
+ input.accountMode === undefined ? "personal" : input.accountMode,
+ sharedVendorUserId:
+ input.accountMode === "shared" ? `openbot-deployment:${suite}` : null,
+ });
+ for (const tool of input.tools ?? ["READ"])
+ await database.insert(mcpTools).values({
+ serverId: input.key,
+ name: tool,
+ description: tool,
+ inputSchema: {},
+ effect: input.effects?.[tool] ?? null,
+ destructive: input.destructive?.includes(tool) ?? false,
+ });
+ if (input.grant !== false)
+ await database.insert(pluginGrants).values({
+ kind: "mcp",
+ ref: `${input.key}/${(input.tools ?? ["READ"])[0]}`,
+ agentId: bot,
+ });
+}
+
+const ourServers = like(mcpServers.id, `%${suite}%`);
+
+/** An administrator's approval of the suite Bot's audience for a Shared app, as granting writes. */
+async function approveAudience(serverId: string) {
+ const sharedUse = createSharedUseStore(database, "composio");
+ await sharedUse.setApproval({
+ botId: bot,
+ serverId,
+ approval: exposureOf(await sharedUse.botFacts(bot)),
+ by: admin,
+ });
+}
+
+async function connectTeamAccount(name: string) {
+ await database.insert(brokeredConnections).values({
+ provider: "composio",
+ app: slug(name),
+ holder: "deployment",
+ vendorUserId: `openbot-deployment:${suite}`,
+ verified: true,
+ });
+}
+
+async function clearApps() {
+ await database
+ .delete(teamBotPublications)
+ .where(inArray(teamBotPublications.agentId, [bot, hidden]));
+ await database
+ .delete(teamBotConsents)
+ .where(inArray(teamBotConsents.userId, [person, other, admin]));
+ await database
+ .delete(appAccessRequests)
+ .where(inArray(appAccessRequests.requestedBy, [person, other, admin]));
+ await database
+ .delete(pluginGrants)
+ .where(inArray(pluginGrants.agentId, [bot, hidden]));
+ await database
+ .delete(brokeredConnections)
+ .where(like(brokeredConnections.app, `%${suite}%`));
+ await database
+ .delete(sharedUseApprovals)
+ .where(eq(sharedUseApprovals.agentId, bot));
+ await database.delete(mcpServers).where(ourServers);
+ await database
+ .delete(mcpUserCredentials)
+ .where(eq(mcpUserCredentials.serverId, "google-drive"));
+ await database.delete(mcpServers).where(eq(mcpServers.id, "google-drive"));
+ await database
+ .delete(credentials)
+ .where(eq(credentials.provider, `google-drive-${suite}`));
+}
+
+beforeAll(async () => {
+ for (const user of [person, other, admin])
+ await database.insert(users).values({
+ id: user,
+ email: `${user}@example.test`,
+ name: user,
+ emailVerified: true,
+ });
+ for (const agent of [bot, hidden]) {
+ await database.insert(agents).values({
+ id: agent,
+ name: `Bot ${agent}`,
+ type: "built_in",
+ configuration: {},
+ });
+ await database.insert(agentProfiles).values({
+ agentId: agent,
+ ownerUserId: person,
+ visibility: "private",
+ title: "Helper",
+ roleDescription: "Helper",
+ avatarSeed: "x",
+ });
+ }
+});
+
+beforeEach(async () => {
+ events.length = 0;
+ enabled.length = 0;
+ beforeGrant = null;
+ await clearApps();
+});
+
+afterAll(async () => {
+ await clearApps();
+ await database.delete(agents).where(inArray(agents.id, [bot, hidden]));
+ await database.delete(users).where(inArray(users.id, [person, other, admin]));
+});
+
+describe("what each app a Bot holds is, for the person asking", () => {
+ test("every state a held app can be in, named by the app's own name", async () => {
+ await seedApp({ key: GMAIL, title: "Gmail" });
+ await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" });
+ await seedApp({ key: LINEAR, title: "Linear", authScheme: "API_KEY" });
+ await seedApp({
+ key: NEWS,
+ title: "Hacker News",
+ authScheme: "NO_AUTH",
+ accountMode: null,
+ });
+ await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false });
+ // A grant whose app is gone: still something this Bot was given.
+ await database
+ .insert(pluginGrants)
+ .values({ kind: "mcp", ref: `${GONE}/SEND`, agentId: bot });
+ // Linear connected, and its key refused by the vendor at the last check.
+ await database.insert(brokeredConnections).values({
+ provider: "composio",
+ app: slug("linear"),
+ holder: "person",
+ userId: person,
+ vendorUserId: `vendor-${person}`,
+ verified: false,
+ probeAction: "LINEAR_LIST",
+ });
+
+ const response = await appAs(person).request(
+ `/api/plugins/for/${bot}/apps`,
+ );
+ expect(response.status).toBe(200);
+ const { apps } = await json<{ apps: AppAccess[] }>(response);
+ const byKey = new Map(apps.map((app) => [app.key, app]));
+
+ expect(byKey.get(GMAIL)).toMatchObject({
+ displayName: "Gmail",
+ state: "not_connected",
+ account: "personal",
+ connect: "consent",
+ requestKind: null,
+ granted: true,
+ });
+ expect(byKey.get(SLACK)).toMatchObject({
+ state: "shared_not_connected",
+ account: "shared",
+ requestKind: "connect_shared",
+ });
+ expect(byKey.get(LINEAR)).toMatchObject({
+ state: "reconnect",
+ connect: "key",
+ });
+ expect(byKey.get(NEWS)).toMatchObject({
+ state: "ready",
+ account: "deployment",
+ });
+ expect(byKey.get(GONE)).toMatchObject({
+ displayName: "Gone App",
+ state: "disabled",
+ requestKind: "enable",
+ });
+ // Not held, so not on the Bot's list.
+ expect(byKey.has(CALENDAR)).toBe(false);
+ // Sorted by name.
+ expect(apps.map((app) => app.displayName)).toEqual(
+ [...apps.map((app) => app.displayName)].sort((a, b) =>
+ a.localeCompare(b),
+ ),
+ );
+ });
+
+ test("a usable connection is ready, and somebody else's is not mine", async () => {
+ await seedApp({ key: GMAIL, title: "Gmail" });
+ await database.insert(brokeredConnections).values({
+ provider: "composio",
+ app: slug("gmail"),
+ holder: "person",
+ userId: other,
+ vendorUserId: `vendor-${other}`,
+ verified: true,
+ connectedAt: new Date("2026-10-20T12:00:00.000Z"),
+ });
+ const mine = await json<{ apps: AppAccess[] }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps`),
+ );
+ expect(mine.apps[0]?.state).toBe("not_connected");
+ // Nothing of theirs leaks into mine, not even when they connected.
+ expect(mine.apps[0]?.connectedAt).toBeNull();
+ const theirs = await json<{ apps: AppAccess[] }>(
+ await appAs(other).request(`/api/plugins/for/${bot}/apps`),
+ );
+ expect(theirs.apps[0]?.state).toBe("ready");
+ // When, so a chat card over a lost account can tell a reconnection from the loss.
+ expect(theirs.apps[0]?.connectedAt).toBe("2026-10-20T12:00:00.000Z");
+ });
+
+ test("a Shared app with its team account connected and its audience approved is ready", async () => {
+ await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" });
+ await approveAudience(SLACK);
+ await database.insert(brokeredConnections).values({
+ provider: "composio",
+ app: slug("slack"),
+ holder: "deployment",
+ vendorUserId: `openbot-deployment:${suite}`,
+ verified: true,
+ connectedAt: new Date("2026-10-20T09:30:00.000Z"),
+ });
+ const { apps } = await json<{ apps: AppAccess[] }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps`),
+ );
+ expect(apps[0]?.state).toBe("ready");
+ // The team account's, since that is the account the app runs in for everybody.
+ expect(apps[0]?.connectedAt).toBe("2026-10-20T09:30:00.000Z");
+ });
+
+ test("with no Composio key every Composio app is switched off", async () => {
+ await seedApp({ key: GMAIL, title: "Gmail" });
+ const { apps } = await json<{ apps: AppAccess[] }>(
+ await appAs(person, "user", { composio: false }).request(
+ `/api/plugins/for/${bot}/apps`,
+ ),
+ );
+ expect(apps[0]).toMatchObject({ state: "disabled", requestKind: "enable" });
+ });
+
+ test("a Bot the person cannot use answers 404", async () => {
+ const response = await appAs(person).request(
+ `/api/plugins/for/${hidden}/apps`,
+ );
+ expect(response.status).toBe(404);
+ });
+
+ test("a per-person catalogue app reads its credential, withdrawn or not", async () => {
+ const [client] = await database
+ .insert(credentials)
+ .values({
+ kind: "mcp_oauth_client",
+ provider: `google-drive-${suite}`,
+ keyId: "oauth-client",
+ metadata: {},
+ encryptedValue: "sealed",
+ })
+ .returning({ id: credentials.id });
+ await database.insert(mcpServers).values({
+ id: "google-drive",
+ title: "Google Drive",
+ vendor: "Google",
+ url: "https://www.googleapis.com/drive/v3",
+ provenance: "first-party",
+ credentialId: client?.id ?? null,
+ });
+ await database
+ .insert(pluginGrants)
+ .values({ kind: "mcp", ref: "google-drive/search", agentId: bot });
+ const [credential] = await database
+ .insert(credentials)
+ .values({
+ kind: "mcp_user_token",
+ provider: `google-drive-${suite}`,
+ keyId: person,
+ metadata: {},
+ encryptedValue: "sealed",
+ revokedAt: new Date(),
+ })
+ .returning({ id: credentials.id });
+ if (!credential) throw new Error("credential not stored");
+ await database.insert(mcpUserCredentials).values({
+ serverId: "google-drive",
+ userId: person,
+ credentialId: credential.id,
+ scope: "drive.readonly",
+ });
+ const { apps } = await json<{ apps: AppAccess[] }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps`),
+ );
+ expect(apps[0]).toMatchObject({
+ key: "google-drive",
+ displayName: "Google Drive",
+ provider: "catalogue",
+ connect: "oauth",
+ state: "reconnect",
+ });
+ });
+});
+
+describe("one app, by key or by name, held or not", () => {
+ test("an enabled app the Bot does not hold is not_granted, found by its name", async () => {
+ await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false });
+ for (const asked of [CALENDAR, slug("calendar"), "google calendar"]) {
+ const response = await appAs(person).request(
+ `/api/plugins/for/${bot}/apps/${encodeURIComponent(asked)}`,
+ );
+ expect(response.status).toBe(200);
+ expect((await json<{ app: AppAccess }>(response)).app).toMatchObject({
+ key: CALENDAR,
+ state: "not_granted",
+ requestKind: "grant",
+ });
+ }
+ });
+
+ test("an app only the directory knows is disabled, under the id enabling would mint", async () => {
+ const response = await appAs(person).request(
+ `/api/plugins/for/${bot}/apps/Dropbox`,
+ );
+ expect((await json<{ app: AppAccess }>(response)).app).toMatchObject({
+ key: DROPBOX,
+ displayName: "Dropbox",
+ provider: "composio",
+ state: "disabled",
+ requestKind: "enable",
+ });
+ });
+
+ test("a catalogue entry nobody added is disabled under its key", async () => {
+ const response = await appAs(person).request(
+ `/api/plugins/for/${bot}/apps/notion`,
+ );
+ expect((await json<{ app: AppAccess }>(response)).app).toMatchObject({
+ key: "notion",
+ displayName: "Notion",
+ provider: "catalogue",
+ state: "disabled",
+ });
+ });
+
+ test("a word that names no app is 404", async () => {
+ const response = await appAs(person).request(
+ `/api/plugins/for/${bot}/apps/${encodeURIComponent("Not An App")}`,
+ );
+ expect(response.status).toBe(404);
+ });
+});
+
+describe("asking for access", () => {
+ test("files the kind the state needs, once, and the status shows it", async () => {
+ await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false });
+ const first = await appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: "Google Calendar" }),
+ );
+ expect(first.status).toBe(201);
+ const filed = await json<{
+ request: AppAccessRequestRecord;
+ created: boolean;
+ }>(first);
+ expect(filed).toMatchObject({
+ created: true,
+ request: {
+ appKey: CALENDAR,
+ appTitle: "Google Calendar",
+ kind: "grant",
+ status: "pending",
+ agentId: bot,
+ requestedBy: person,
+ },
+ });
+
+ const again = await appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: CALENDAR }),
+ );
+ expect(again.status).toBe(200);
+ expect(
+ await json<{ request: { id: string }; created: boolean }>(again),
+ ).toMatchObject({
+ created: false,
+ request: { id: filed.request.id },
+ });
+ expect(eventsOf("app_access.requested")).toHaveLength(1);
+
+ const status = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${CALENDAR}`),
+ );
+ expect(status.app.request).toMatchObject({
+ id: filed.request.id,
+ kind: "grant",
+ });
+ // Somebody else asking files their own.
+ const theirs = await appAs(other).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: CALENDAR }),
+ );
+ expect(theirs.status).toBe(201);
+ });
+
+ test("two presses at once still leave one pending request", async () => {
+ await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false });
+ const responses = await Promise.all(
+ [1, 2, 3].map(() =>
+ appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: CALENDAR }),
+ ),
+ ),
+ );
+ const ids = new Set(
+ await Promise.all(
+ responses.map(
+ async (response) =>
+ (await json<{ request: { id: string } }>(response)).request.id,
+ ),
+ ),
+ );
+ expect(ids.size).toBe(1);
+ const rows = await database
+ .select()
+ .from(appAccessRequests)
+ .where(
+ and(
+ eq(appAccessRequests.requestedBy, person),
+ eq(appAccessRequests.status, "pending"),
+ ),
+ );
+ expect(rows).toHaveLength(1);
+ });
+
+ test("a state the person can fix themselves is refused, and the refusal is in the trail", async () => {
+ await seedApp({ key: GMAIL, title: "Gmail" });
+ const response = await appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: GMAIL }),
+ );
+ expect(response.status).toBe(409);
+ expect(eventsOf("app_access.refused")[0]?.payload).toMatchObject({
+ app: GMAIL,
+ reason: "nothing_to_request:not_connected",
+ });
+ });
+
+ test("a Bot the person cannot use, and an unknown app, are refused and recorded", async () => {
+ const hiddenBot = await appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: hidden, app: "Dropbox" }),
+ );
+ expect(hiddenBot.status).toBe(404);
+ const unknown = await appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: "Not An App" }),
+ );
+ expect(unknown.status).toBe(404);
+ expect(
+ eventsOf("app_access.refused").map((event) => event.payload.reason),
+ ).toEqual(["no_such_bot", "no_such_app"]);
+ const malformed = await appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: 7 }),
+ );
+ expect(malformed.status).toBe(400);
+ });
+
+ test("a person lists and cancels their own, and cannot touch anybody else's", async () => {
+ await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false });
+ const { request } = await json<{ request: AppAccessRequestRecord }>(
+ await appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: CALENDAR }),
+ ),
+ );
+ const mine = await json<{ requests: AppAccessRequestRecord[] }>(
+ await appAs(person).request("/api/plugins/access-requests/mine"),
+ );
+ expect(mine.requests.map((row) => row.id)).toEqual([request.id]);
+ expect(
+ (
+ await json<{ requests: AppAccessRequestRecord[] }>(
+ await appAs(other).request("/api/plugins/access-requests/mine"),
+ )
+ ).requests,
+ ).toEqual([]);
+
+ const notTheirs = await appAs(other).request(
+ `/api/plugins/access-requests/${request.id}`,
+ { method: "DELETE" },
+ );
+ expect(notTheirs.status).toBe(404);
+
+ const cancelled = await appAs(person).request(
+ `/api/plugins/access-requests/${request.id}`,
+ { method: "DELETE" },
+ );
+ expect(cancelled.status).toBe(200);
+ expect(
+ (await json<{ request: AppAccessRequestRecord }>(cancelled)).request,
+ ).toMatchObject({ status: "cancelled", resolvedBy: person });
+ expect(eventsOf("app_access.cancelled")).toHaveLength(1);
+
+ const twice = await appAs(person).request(
+ `/api/plugins/access-requests/${request.id}`,
+ { method: "DELETE" },
+ );
+ expect(twice.status).toBe(409);
+ });
+});
+
+describe("answering a request", () => {
+ async function file(app: string, as = person) {
+ const response = await appAs(as).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app }),
+ );
+ return (await json<{ request: AppAccessRequestRecord }>(response)).request;
+ }
+
+ test("only an administrator sees the inbox and its counts", async () => {
+ await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false });
+ await file(CALENDAR);
+ await file(CALENDAR, other);
+ await file("Dropbox");
+
+ expect(
+ (await appAs(person).request("/api/plugins/access-requests")).status,
+ ).toBe(403);
+ const inbox = await json<{
+ requests: AppAccessRequestRecord[];
+ counts: { total: number; byApp: Record };
+ }>(
+ await appAs(admin, "admin").request(
+ `/api/plugins/access-requests?app=${CALENDAR}`,
+ ),
+ );
+ expect(inbox.requests).toHaveLength(2);
+ expect(inbox.requests[0]).toMatchObject({
+ botName: `Bot ${bot}`,
+ requesterEmail: expect.stringContaining("@example.test"),
+ });
+ expect(inbox.counts.byApp[CALENDAR]).toBe(2);
+ expect(inbox.counts.byApp[DROPBOX]).toBe(1);
+ expect(inbox.counts.total).toBeGreaterThanOrEqual(3);
+ });
+
+ test("approving a grant request grants the app's tools and records the Shared approval", async () => {
+ await seedApp({
+ key: SLACK,
+ title: "Slack",
+ accountMode: "shared",
+ tools: ["SEND", "LIST"],
+ effects: { LIST: "read", SEND: "write" },
+ grant: false,
+ });
+ const request = await file(SLACK);
+ expect(request.kind).toBe("grant");
+
+ expect(
+ (
+ await appAs(person).request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ )
+ ).status,
+ ).toBe(403);
+
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(200);
+ // The read only: sending is chosen on the Bot's page, never handed out by one press.
+ expect(await response.json()).toMatchObject({
+ request: { status: "approved", resolvedBy: admin },
+ enabled: false,
+ granted: [`${SLACK}/LIST`],
+ });
+ const grants = await database
+ .select({ ref: pluginGrants.ref })
+ .from(pluginGrants)
+ .where(eq(pluginGrants.agentId, bot));
+ expect(grants.map((grant) => grant.ref).sort()).toEqual([`${SLACK}/LIST`]);
+ const [approval] = await database
+ .select()
+ .from(sharedUseApprovals)
+ .where(eq(sharedUseApprovals.agentId, bot));
+ expect(approval?.serverId).toBe(SLACK);
+ expect(eventsOf("app_access.approved")[0]?.payload).toMatchObject({
+ request: request.id,
+ kind: "grant",
+ granted: 1,
+ });
+
+ // The person now sees the next step, which is not theirs to ask for.
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`),
+ );
+ expect(app).toMatchObject({ state: "shared_not_connected", request: null });
+
+ const twice = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(twice.status).toBe(409);
+ });
+
+ test("`refs` narrows the grant, and a ref outside the app is refused", async () => {
+ await seedApp({
+ key: CALENDAR,
+ title: "Google Calendar",
+ tools: ["READ", "WRITE"],
+ grant: false,
+ });
+ const request = await file(CALENDAR);
+ const outside = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ post({ refs: [`${GMAIL}/READ`] }),
+ );
+ expect(outside.status).toBe(400);
+ expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({
+ reason: "refs_outside_app",
+ request: request.id,
+ });
+ const narrowed = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ post({ refs: [`${CALENDAR}/READ`] }),
+ );
+ expect(narrowed.status).toBe(200);
+ expect((await json<{ granted: string[] }>(narrowed)).granted).toEqual([
+ `${CALENDAR}/READ`,
+ ]);
+ });
+
+ test("approving an enable request switches the app on and grants it to the Bot", async () => {
+ const request = await file("Dropbox");
+ expect(request).toMatchObject({ kind: "enable", appKey: DROPBOX });
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ enabled: true,
+ granted: [`${DROPBOX}/LIST_FILES`],
+ });
+ expect(enabled).toEqual([slug("dropbox")]);
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${DROPBOX}`),
+ );
+ expect(app).toMatchObject({
+ state: "not_connected",
+ displayName: "Dropbox",
+ });
+ });
+
+ test("an app that needs what only an administrator can type stays pending", async () => {
+ const request = await file("google-drive");
+ expect(request.kind).toBe("enable");
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(409);
+ expect((await json<{ error: string }>(response)).error).toContain(
+ "OAuth client",
+ );
+ const [row] = await database
+ .select({ status: appAccessRequests.status })
+ .from(appAccessRequests)
+ .where(eq(appAccessRequests.id, request.id));
+ expect(row?.status).toBe("pending");
+ expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({
+ reason: "needs_admin_input",
+ });
+ });
+
+ test("with no Composio key an enable request is refused with the setting to change", async () => {
+ await seedApp({ key: GMAIL, title: "Gmail" });
+ const response = await appAs(person, "user", { composio: false }).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: GMAIL }),
+ );
+ const { request } = await json<{ request: AppAccessRequestRecord }>(
+ response,
+ );
+ expect(request.kind).toBe("enable");
+ const approve = await appAs(admin, "admin", { composio: false }).request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(approve.status).toBe(503);
+ expect((await json<{ error: string }>(approve)).error).toContain(
+ "COMPOSIO_API_KEY",
+ );
+ });
+
+ test("a team-account request is approved only once the account is connected", async () => {
+ await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" });
+ await approveAudience(SLACK);
+ const request = await file(SLACK);
+ expect(request.kind).toBe("connect_shared");
+ const early = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(early.status).toBe(409);
+ await database.insert(brokeredConnections).values({
+ provider: "composio",
+ app: slug("slack"),
+ holder: "deployment",
+ vendorUserId: `openbot-deployment:${suite}`,
+ verified: true,
+ });
+ const later = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(later.status).toBe(200);
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`),
+ );
+ expect(app.state).toBe("ready");
+ });
+
+ test("dismissing changes nothing but the request", async () => {
+ await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false });
+ const request = await file(CALENDAR);
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/dismiss`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(200);
+ expect(
+ (await json<{ request: AppAccessRequestRecord }>(response)).request,
+ ).toMatchObject({ status: "dismissed", resolvedBy: admin });
+ expect(eventsOf("app_access.dismissed")).toHaveLength(1);
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${CALENDAR}`),
+ );
+ expect(app).toMatchObject({ state: "not_granted", request: null });
+ const again = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/dismiss`,
+ { method: "POST" },
+ );
+ expect(again.status).toBe(409);
+ });
+});
+
+/*
+ * THE SAME RULE ON AN ARCADE DEPLOYMENT. Which rows are brokered apps, which connections count and
+ * which id an app nobody enabled would get all come from the store's broker, so a personal Arcade
+ * app reads exactly as a personal Composio app does — under Arcade's case-sensitive toolkit name.
+ */
+describe("on an Arcade deployment", () => {
+ const toolkit = `ArcMail_${suite}`;
+ const ARC_MAIL = `arcade-${toolkit}`;
+ const docs = `ArcDocs_${suite}`;
+
+ function arcadeStore(): PluginStore {
+ return createPluginStore({
+ brokerProvider: "arcade",
+ database,
+ auditStore: audit,
+ credentials: {} as never,
+ encryptionKey: "x".repeat(44),
+ policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }),
+ broker,
+ deploymentId: `acme-${suite}`,
+ });
+ }
+
+ async function seedArcadeApp() {
+ await database.insert(mcpServers).values({
+ id: ARC_MAIL,
+ title: "Arc Mail",
+ vendor: "Arcade",
+ url: `arcade://${toolkit}`,
+ provenance: "arcade",
+ authScheme: "OAUTH2",
+ accountMode: "personal",
+ });
+ await database.insert(mcpTools).values({
+ serverId: ARC_MAIL,
+ name: "READ",
+ description: "READ",
+ inputSchema: {},
+ });
+ await database
+ .insert(pluginGrants)
+ .values({ kind: "mcp", ref: `${ARC_MAIL}/READ`, agentId: bot });
+ }
+
+ const forBot = (composioConfigured = true) =>
+ arcadeStore().appAccessForBot({
+ userId: person,
+ agentId: bot,
+ composioConfigured,
+ });
+
+ test("a personal app is not_connected until the person connects it, then ready", async () => {
+ await seedArcadeApp();
+ expect(await forBot()).toMatchObject([
+ {
+ key: ARC_MAIL,
+ provider: "arcade",
+ account: "personal",
+ connect: "consent",
+ authScheme: "OAUTH2",
+ state: "not_connected",
+ },
+ ]);
+ // Somebody else's connection, and a Composio one at the same app name, are not this person's.
+ await database.insert(brokeredConnections).values([
+ {
+ provider: "arcade",
+ app: toolkit,
+ holder: "person",
+ userId: other,
+ vendorUserId: `vendor-${other}`,
+ verified: true,
+ },
+ {
+ provider: "composio",
+ app: toolkit,
+ holder: "person",
+ userId: person,
+ vendorUserId: `vendor-${person}`,
+ verified: true,
+ },
+ ]);
+ expect((await forBot())[0]?.state).toBe("not_connected");
+ await database.insert(brokeredConnections).values({
+ provider: "arcade",
+ app: toolkit,
+ holder: "person",
+ userId: person,
+ vendorUserId: `vendor-${person}`,
+ verified: true,
+ connectedAt: new Date("2026-10-21T08:00:00.000Z"),
+ });
+ expect(await forBot()).toMatchObject([
+ {
+ key: ARC_MAIL,
+ state: "ready",
+ connectedAt: "2026-10-21T08:00:00.000Z",
+ },
+ ]);
+ });
+
+ test("with no Arcade key the app is switched off at the deployment", async () => {
+ await seedArcadeApp();
+ const inspected = await arcadeStore().inspectApp({
+ app: ARC_MAIL,
+ agentId: bot,
+ userId: person,
+ composioConfigured: false,
+ });
+ expect(inspected?.unavailable).toBe("composio_unconfigured");
+ expect(inspected?.access.state).toBe("disabled");
+ });
+
+ test("an app is found by its id or its toolkit name as typed", async () => {
+ await seedArcadeApp();
+ for (const asked of [ARC_MAIL, toolkit, "arc mail"]) {
+ const inspected = await arcadeStore().inspectApp({
+ app: asked,
+ agentId: bot,
+ userId: person,
+ composioConfigured: true,
+ });
+ expect(inspected?.access.key).toBe(ARC_MAIL);
+ expect(inspected?.toolkit).toBe(toolkit);
+ }
+ });
+
+ test("an app only the directory knows is disabled, under the arcade- id enabling would mint", async () => {
+ const inspected = await arcadeStore().inspectApp({
+ app: "Arc Docs",
+ agentId: bot,
+ userId: person,
+ composioConfigured: true,
+ directory: async () => [
+ {
+ slug: docs,
+ name: "Arc Docs",
+ description: "",
+ logo: null,
+ categories: [],
+ actionCount: 1,
+ classifiedCount: null,
+ permissionCount: null,
+ scopedActionCount: null,
+ connection: { kind: "consent" },
+ },
+ ],
+ });
+ expect(inspected?.access).toMatchObject({
+ key: `arcade-${docs}`,
+ displayName: "Arc Docs",
+ provider: "arcade",
+ state: "disabled",
+ requestKind: "enable",
+ });
+ expect(inspected?.toolkit).toBe(docs);
+ });
+});
+
+describe("what approving grants, and when it can be answered", () => {
+ async function file(app: string, as = person) {
+ const response = await appAs(as).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app }),
+ );
+ return (await json<{ request: AppAccessRequestRecord }>(response)).request;
+ }
+ const statusOf = async (id: string) =>
+ (
+ await database
+ .select({ status: appAccessRequests.status })
+ .from(appAccessRequests)
+ .where(eq(appAccessRequests.id, id))
+ )[0]?.status;
+
+ test("without refs only the read-only actions are granted, never a send or a delete", async () => {
+ await seedApp({
+ key: GMAIL,
+ title: "Gmail",
+ tools: ["READ_MAIL", "SEND_MAIL", "DELETE_ALL", "UNLABELLED"],
+ effects: { READ_MAIL: "read", SEND_MAIL: "write", DELETE_ALL: "write" },
+ destructive: ["DELETE_ALL"],
+ grant: false,
+ });
+ const request = await file("Gmail");
+ expect(request.kind).toBe("grant");
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(200);
+ expect((await json<{ granted: string[] }>(response)).granted).toEqual([
+ `${GMAIL}/READ_MAIL`,
+ ]);
+ const grants = await database
+ .select({ ref: pluginGrants.ref })
+ .from(pluginGrants)
+ .where(eq(pluginGrants.agentId, bot));
+ expect(grants.map((grant) => grant.ref)).toEqual([`${GMAIL}/READ_MAIL`]);
+ });
+
+ test("an app with no read-only action is refused with where to choose, and stays pending", async () => {
+ await seedApp({
+ key: GMAIL,
+ title: "Gmail",
+ tools: ["SEND_MAIL"],
+ effects: { SEND_MAIL: "write" },
+ grant: false,
+ });
+ const request = await file("Gmail");
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(409);
+ expect((await json<{ error: string }>(response)).error).toContain(
+ "read-only",
+ );
+ expect(await statusOf(request.id)).toBe("pending");
+ expect(
+ await database
+ .select()
+ .from(pluginGrants)
+ .where(eq(pluginGrants.agentId, bot)),
+ ).toEqual([]);
+ // Naming the actions is how an administrator grants more than the reads.
+ const chosen = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ post({ refs: [`${GMAIL}/SEND_MAIL`] }),
+ );
+ expect(chosen.status).toBe(200);
+ });
+
+ for (const [loser, answer] of [
+ [
+ "a dismissal",
+ (id: string) =>
+ appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${id}/dismiss`,
+ { method: "POST" },
+ ),
+ ],
+ [
+ "a withdrawal",
+ (id: string) =>
+ appAs(person).request(`/api/plugins/access-requests/${id}`, {
+ method: "DELETE",
+ }),
+ ],
+ ] as const) {
+ test(`${loser} arriving while an approval is granting loses to it`, async () => {
+ await seedApp({
+ key: CALENDAR,
+ title: "Google Calendar",
+ tools: ["READ"],
+ effects: { READ: "read" },
+ grant: false,
+ });
+ const request = await file(CALENDAR);
+
+ let release = () => {};
+ let granting = () => {};
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ const reached = new Promise((resolve) => {
+ granting = resolve;
+ });
+ beforeGrant = async () => {
+ granting();
+ await held;
+ };
+ try {
+ const approving = appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ await reached;
+ // Answered while the grant is still being written: the approval already claimed it.
+ const lost = await answer(request.id);
+ expect(lost.status).toBe(409);
+ release();
+ const approved = await approving;
+ expect(approved.status).toBe(200);
+ expect(await statusOf(request.id)).toBe("approved");
+ expect(eventsOf("app_access.approved")).toHaveLength(1);
+ expect(eventsOf("app_access.dismissed")).toHaveLength(0);
+ expect(eventsOf("app_access.cancelled")).toHaveLength(0);
+ } finally {
+ release();
+ beforeGrant = null;
+ }
+ });
+ }
+
+ test("an approval that throws partway puts the request back", async () => {
+ await seedApp({
+ key: CALENDAR,
+ title: "Google Calendar",
+ tools: ["READ"],
+ effects: { READ: "read" },
+ grant: false,
+ });
+ const request = await file(CALENDAR);
+ beforeGrant = async () => {
+ throw new Error("the grant failed");
+ };
+ const response = await appAs(admin, "admin").request(
+ `/api/plugins/access-requests/${request.id}/approve`,
+ { method: "POST" },
+ );
+ expect(response.status).toBe(500);
+ expect(await statusOf(request.id)).toBe("pending");
+ expect(eventsOf("app_access.approved")).toHaveLength(0);
+ });
+
+ test("a request filed again while the first was claimed is the one left waiting", async () => {
+ await seedApp({
+ key: GMAIL,
+ title: "Gmail",
+ tools: ["SEND_MAIL"],
+ effects: { SEND_MAIL: "write" },
+ grant: false,
+ });
+ const first = await file("Gmail");
+ const store = storeFor();
+ expect(
+ await store.claimAppAccessRequest({ id: first.id, by: admin }),
+ ).not.toBeNull();
+ const second = await file("Gmail");
+ expect(second.id).not.toBe(first.id);
+ await store.releaseAppAccessRequest({ id: first.id, by: admin });
+ expect(await statusOf(first.id)).toBe("cancelled");
+ expect(await statusOf(second.id)).toBe("pending");
+ });
+});
+
+describe("what a request may carry", () => {
+ const ask = (app: string, agentId = bot) =>
+ appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId, app }),
+ );
+
+ test("an app or Bot name past the cap is refused before anything is read or written", async () => {
+ const long = "x".repeat(201);
+ expect((await ask(`composio-${long}`)).status).toBe(400);
+ expect((await ask(GMAIL, long)).status).toBe(400);
+ expect(eventsOf("app_access.refused")).toEqual([]);
+ expect(
+ (await appAs(person).request(`/api/plugins/for/${bot}/apps/${long}`))
+ .status,
+ ).toBe(404);
+ });
+
+ test("enabling a composio- id the directory does not list is no such app", async () => {
+ const response = await ask(row("invented"));
+ expect(response.status).toBe(404);
+ const filed = await database
+ .select()
+ .from(appAccessRequests)
+ .where(eq(appAccessRequests.requestedBy, person));
+ expect(filed).toEqual([]);
+ });
+
+ test("a grant that outlived its app can still be asked about", async () => {
+ await database
+ .insert(pluginGrants)
+ .values({ kind: "mcp", ref: `${GONE}/SEND`, agentId: bot });
+ const response = await appAs(person, "user", { composio: false }).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: GONE }),
+ );
+ expect(response.status).toBe(201);
+ });
+
+ test(`at ${MAX_PENDING_REQUESTS} waiting a new one is refused, and one already waiting is still shown`, async () => {
+ await database.insert(appAccessRequests).values(
+ Array.from({ length: MAX_PENDING_REQUESTS - 1 }, (_, index) => ({
+ id: randomUUID(),
+ requestedBy: person,
+ agentId: bot,
+ appKey: `composio-filler${index}_${suite}`,
+ appTitle: `Filler ${index}`,
+ kind: "enable" as const,
+ })),
+ );
+ await seedApp({ key: CALENDAR, title: "Google Calendar", grant: false });
+ const last = await ask(CALENDAR);
+ expect(last.status).toBe(201);
+ const over = await ask("Dropbox");
+ expect(over.status).toBe(429);
+ expect(eventsOf("app_access.refused").at(-1)?.payload).toMatchObject({
+ reason: "too_many_pending",
+ });
+ const again = await ask(CALENDAR);
+ expect(again.status).toBe(200);
+ expect((await json<{ created: boolean }>(again)).created).toBe(false);
+ });
+});
+
+describe("finding an app by its name", () => {
+ test("a name finds the enabled app before a catalogue entry nobody added", async () => {
+ const DRIVE = row("googledrive");
+ await seedApp({ key: DRIVE, title: "Google Drive", grant: false });
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(
+ `/api/plugins/for/${bot}/apps/Google%20Drive`,
+ ),
+ );
+ expect(app).toMatchObject({
+ key: DRIVE,
+ state: "not_granted",
+ requestKind: "grant",
+ });
+ // The entry is still what its own key names.
+ const { app: entry } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/google-drive`),
+ );
+ expect(entry).toMatchObject({ key: "google-drive", state: "disabled" });
+ });
+
+ test("of two enabled apps with one name, the one this Bot holds", async () => {
+ await seedApp({ key: row("drive_a"), title: "Drive", grant: false });
+ await seedApp({ key: row("drive_b"), title: "Drive" });
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/Drive`),
+ );
+ expect(app.key).toBe(row("drive_b"));
+ });
+});
+
+describe("why an app is off, and a Shared app's audience", () => {
+ test("a switched-off app says why", async () => {
+ await seedApp({ key: GMAIL, title: "Gmail" });
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person, "user", { composio: false }).request(
+ `/api/plugins/for/${bot}/apps/${GMAIL}`,
+ ),
+ );
+ expect(app).toMatchObject({
+ state: "disabled",
+ unavailable: "composio_unconfigured",
+ });
+ const { app: ready } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${GMAIL}`),
+ );
+ expect(ready.unavailable).toBeNull();
+ });
+
+ test("a Shared app whose audience nobody approved is refused, with nothing to file", async () => {
+ await seedApp({ key: SLACK, title: "Slack", accountMode: "shared" });
+ await connectTeamAccount("slack");
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`),
+ );
+ expect(app).toMatchObject({
+ state: "shared_not_approved",
+ requestKind: null,
+ });
+ const filed = await appAs(person).request(
+ "/api/plugins/access-requests",
+ post({ agentId: bot, app: SLACK }),
+ );
+ expect(filed.status).toBe(409);
+ await approveAudience(SLACK);
+ const { app: approved } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${SLACK}`),
+ );
+ expect(approved.state).toBe("ready");
+ });
+});
+
+describe("a teammate on somebody else's Team Bot", () => {
+ beforeEach(async () => {
+ await database
+ .insert(teamBotPublications)
+ .values({ agentId: bot, publishedBy: person, audience: "team" });
+ await seedApp({ key: GMAIL, title: "Gmail" });
+ });
+ const connect = (
+ userId: string,
+ over: { verified?: boolean; probeAction?: string } = {},
+ ) =>
+ database.insert(brokeredConnections).values({
+ provider: "composio",
+ app: slug("gmail"),
+ holder: "person",
+ userId,
+ vendorUserId: `vendor-${userId}`,
+ verified: over.verified ?? true,
+ ...(over.probeAction ? { probeAction: over.probeAction } : {}),
+ });
+ const asTeammate = async () =>
+ (
+ await json<{ app: AppAccess }>(
+ await appAs(other).request(`/api/plugins/for/${bot}/apps/${GMAIL}`),
+ )
+ ).app;
+
+ test("the owner's connection is the one their calls use", async () => {
+ await connect(person);
+ expect(await asTeammate()).toMatchObject({
+ state: "ready",
+ accountHolder: "owner",
+ asksFirst: false,
+ });
+ });
+
+ test("the owner's refused connection is theirs to reconnect, not the teammate's", async () => {
+ await connect(person, { verified: false, probeAction: "GMAIL_LIST" });
+ await connect(other);
+ expect(await asTeammate()).toMatchObject({
+ state: "reconnect",
+ accountHolder: "owner",
+ requestKind: null,
+ });
+ });
+
+ test("with no owner account, the teammate's own, asked first until they always allow it", async () => {
+ expect(await asTeammate()).toMatchObject({
+ state: "not_connected",
+ accountHolder: "you",
+ });
+ await connect(other);
+ expect(await asTeammate()).toMatchObject({
+ state: "ready",
+ accountHolder: "you",
+ asksFirst: true,
+ });
+ await database.insert(teamBotConsents).values({
+ userId: other,
+ agentId: bot,
+ serverId: GMAIL,
+ decision: "always",
+ });
+ expect((await asTeammate()).asksFirst).toBe(false);
+ });
+
+ test("the owner reads their own account, and is never asked", async () => {
+ await connect(person);
+ const { app } = await json<{ app: AppAccess }>(
+ await appAs(person).request(`/api/plugins/for/${bot}/apps/${GMAIL}`),
+ );
+ expect(app).toMatchObject({
+ state: "ready",
+ accountHolder: "you",
+ asksFirst: false,
+ });
+ });
+});
diff --git a/server/tests/app-access-state.test.ts b/server/tests/app-access-state.test.ts
new file mode 100644
index 0000000000..e2e4428b7a
--- /dev/null
+++ b/server/tests/app-access-state.test.ts
@@ -0,0 +1,196 @@
+// server/tests/app-access-state.test.ts
+import { describe, expect, test } from "bun:test";
+import {
+ type AppAccessFacts,
+ accountOf,
+ appAccessState,
+ connectOf,
+ REQUEST_KIND_FOR,
+ unavailableReason,
+} from "../src/plugins/app-access";
+import { catalogueEntry } from "../src/plugins/catalogue";
+
+/**
+ * The rule one app's state is decided by, case by case, with every fact spelled out.
+ *
+ * The store gathers these facts from the tables; this file pins what the rule makes of them, so a
+ * change to the rule shows up as a changed expectation here rather than as a Bot page that quietly
+ * says something different.
+ */
+
+const composio = (overrides: Partial = {}): AppAccessFacts => ({
+ provider: "composio",
+ present: true,
+ composioConfigured: true,
+ authScheme: "OAUTH2",
+ accountMode: "personal",
+ catalogueAuth: null,
+ hasCredential: false,
+ granted: true,
+ personal: null,
+ shared: null,
+ sharedAdmitted: null,
+ ...overrides,
+});
+
+const drive = catalogueEntry("google-drive");
+const notion = catalogueEntry("notion");
+const parallel = catalogueEntry("parallel-authenticated");
+if (!drive || !notion || !parallel)
+ throw new Error("The catalogue entries these cases use are missing.");
+
+const catalogue = (
+ auth: AppAccessFacts["catalogueAuth"],
+ overrides: Partial = {},
+): AppAccessFacts => ({
+ provider: "catalogue",
+ present: true,
+ composioConfigured: false,
+ authScheme: null,
+ accountMode: null,
+ catalogueAuth: auth,
+ hasCredential: true,
+ granted: true,
+ personal: null,
+ shared: null,
+ sharedAdmitted: null,
+ ...overrides,
+});
+
+describe("a Composio app used under each person's own account", () => {
+ test("not connected, connected, and connected with a refused key", () => {
+ expect(appAccessState(composio())).toBe("not_connected");
+ expect(appAccessState(composio({ personal: { usable: true } }))).toBe(
+ "ready",
+ );
+ expect(appAccessState(composio({ personal: { usable: false } }))).toBe(
+ "reconnect",
+ );
+ });
+
+ test("tells a key form from a consent screen", () => {
+ expect(connectOf(composio())).toBe("consent");
+ expect(connectOf(composio({ authScheme: "API_KEY" }))).toBe("key");
+ expect(accountOf(composio())).toBe("personal");
+ });
+
+ test("the Bot holding none of it is not_granted, whatever the person has", () => {
+ expect(
+ appAccessState(composio({ granted: false, personal: { usable: true } })),
+ ).toBe("not_granted");
+ });
+
+ test("off at the deployment is disabled before anything else is asked", () => {
+ for (const facts of [
+ composio({ present: false }),
+ composio({ composioConfigured: false }),
+ composio({ authScheme: null }),
+ composio({ granted: false, present: false }),
+ ])
+ expect(appAccessState(facts)).toBe("disabled");
+ expect(unavailableReason(composio({ composioConfigured: false }))).toBe(
+ "composio_unconfigured",
+ );
+ expect(unavailableReason(composio({ authScheme: "SOMETHING_NEW" }))).toBe(
+ "scheme_unreadable",
+ );
+ });
+});
+
+describe("a Shared Composio app", () => {
+ test("is ready only with a usable team account, whatever the person holds", () => {
+ const shared = (over: Partial) =>
+ composio({ accountMode: "shared", ...over });
+ expect(appAccessState(shared({}))).toBe("shared_not_connected");
+ expect(appAccessState(shared({ personal: { usable: true } }))).toBe(
+ "shared_not_connected",
+ );
+ expect(appAccessState(shared({ shared: { usable: false } }))).toBe(
+ "shared_not_connected",
+ );
+ expect(appAccessState(shared({ shared: { usable: true } }))).toBe("ready");
+ // Connected, and the gate would refuse this person's calls through this Bot.
+ expect(
+ appAccessState(
+ shared({ shared: { usable: true }, sharedAdmitted: false }),
+ ),
+ ).toBe("shared_not_approved");
+ expect(
+ appAccessState(
+ shared({ shared: { usable: true }, sharedAdmitted: true }),
+ ),
+ ).toBe("ready");
+ // The audience matters only once there is an account to reach.
+ expect(appAccessState(shared({ sharedAdmitted: false }))).toBe(
+ "shared_not_connected",
+ );
+ expect(accountOf(shared({}))).toBe("shared");
+ });
+});
+
+describe("an app with no account at all", () => {
+ test("a no-auth Composio app is ready once granted", () => {
+ const facts = composio({ authScheme: "NO_AUTH" });
+ expect(appAccessState(facts)).toBe("ready");
+ expect(accountOf(facts)).toBe("deployment");
+ expect(connectOf(facts)).toBeNull();
+ });
+
+ test("a server added by URL is ready once granted", () => {
+ expect(
+ appAccessState({
+ ...catalogue(null),
+ provider: "custom",
+ hasCredential: false,
+ }),
+ ).toBe("ready");
+ });
+
+ test("a deployment-token entry needs its token first", () => {
+ expect(appAccessState(catalogue(parallel.auth))).toBe("ready");
+ expect(
+ appAccessState(catalogue(parallel.auth, { hasCredential: false })),
+ ).toBe("disabled");
+ expect(
+ unavailableReason(catalogue(parallel.auth, { hasCredential: false })),
+ ).toBe("token_missing");
+ });
+});
+
+describe("a catalogue app each person signs in to", () => {
+ test("a manual OAuth client must be registered before anybody can connect", () => {
+ expect(
+ unavailableReason(catalogue(drive.auth, { hasCredential: false })),
+ ).toBe("oauth_client_missing");
+ expect(appAccessState(catalogue(drive.auth))).toBe("not_connected");
+ expect(connectOf(catalogue(drive.auth))).toBe("oauth");
+ });
+
+ test("a self-registering client is ready as soon as the row is", () => {
+ expect(
+ appAccessState(catalogue(notion.auth, { hasCredential: false })),
+ ).toBe("not_connected");
+ });
+
+ test("a withdrawn credential is reconnect", () => {
+ expect(
+ appAccessState(catalogue(drive.auth, { personal: { usable: false } })),
+ ).toBe("reconnect");
+ expect(
+ appAccessState(catalogue(drive.auth, { personal: { usable: true } })),
+ ).toBe("ready");
+ });
+});
+
+test("only the three states an administrator can fix file a request", () => {
+ expect(REQUEST_KIND_FOR).toEqual({
+ ready: null,
+ not_connected: null,
+ reconnect: null,
+ shared_not_connected: "connect_shared",
+ // The refused call files the administrator's request itself.
+ shared_not_approved: null,
+ disabled: "enable",
+ not_granted: "grant",
+ });
+});
diff --git a/server/tests/app-access-tool.test.ts b/server/tests/app-access-tool.test.ts
new file mode 100644
index 0000000000..74fcbc06b3
--- /dev/null
+++ b/server/tests/app-access-tool.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, test } from "bun:test";
+import {
+ readConnectPrompt,
+ SHOW_APP_ACCESS_TOOL,
+} from "@openbot/shared/connect-prompt";
+import type { AppAccess, AppAccessState } from "../src/plugins/app-access";
+import { appAccessTools } from "../src/plugins/app-access-tool";
+import type { AppInspection } from "../src/plugins/plugin-store/app-access";
+import { PluginRefusedError, type PluginStore } from "../src/plugins/store";
+import { grantedTools, REFUSAL_MARKER } from "../src/plugins/tools";
+
+/**
+ * The tool every Bot holds for putting an app it cannot use in front of the person, and the Bot's
+ * name on the refusals that already carried a card.
+ *
+ * The tool is a read: it asks the store what the app is for this person on this Bot and answers
+ * with the card marker — the app, the Bot, `reason: "access"` — and a sentence for the model. It
+ * never files a request; pressing the card does that, as the person.
+ */
+
+function access(state: AppAccessState): AppAccess {
+ return {
+ key: "composio-gmail",
+ displayName: "Gmail",
+ logo: null,
+ provider: "composio",
+ account: "personal",
+ connect: "consent",
+ authScheme: "OAUTH2",
+ accountMode: "personal",
+ state,
+ unavailable: state === "disabled" ? "absent" : null,
+ accountHolder: "you",
+ asksFirst: false,
+ granted: state !== "not_granted" && state !== "disabled",
+ connectedAt: null,
+ requestKind: null,
+ request: null,
+ };
+}
+
+function toolOver(answer: AppAccess | null) {
+ const asked: unknown[] = [];
+ const [tool] = appAccessTools({
+ store: {
+ inspectApp: async (input) => {
+ asked.push(input);
+ return answer ? ({ access: answer } as unknown as AppInspection) : null;
+ },
+ },
+ botId: "expenses",
+ actorId: "usr_7",
+ composioConfigured: true,
+ });
+ if (!tool) throw new Error("no tool");
+ return { tool, asked };
+}
+
+describe("show_app_access", () => {
+ test("is offered under its shared name, as a read anybody may hold", () => {
+ const { tool } = toolOver(null);
+ expect(tool.name).toBe(SHOW_APP_ACCESS_TOOL);
+ expect(tool.description).toContain("instead of only saying it is missing");
+ });
+
+ test("asks about the app for this person on this Bot", async () => {
+ const { tool, asked } = toolOver(access("disabled"));
+ await tool.execute({ app: "Gmail" });
+ expect(asked).toEqual([
+ {
+ app: "Gmail",
+ agentId: "expenses",
+ userId: "usr_7",
+ composioConfigured: true,
+ },
+ ]);
+ });
+
+ test.each([
+ ["ready", /ready for this person to use through you/],
+ ["not_connected", /offers Connect/],
+ ["reconnect", /offers Reconnect/],
+ ["shared_not_connected", /ask an administrator for it/],
+ ["disabled", /switched off on this deployment/],
+ ["not_granted", /you have not been granted it/],
+ ] as const)(
+ "answers %s with the card marker and the state in words",
+ async (state, said) => {
+ const { tool } = toolOver(access(state));
+ const answer = await tool.execute({ app: "gmail" });
+ // Not a refusal: the transcript reads this tool's ordinary answer for its card.
+ expect(answer.startsWith(REFUSAL_MARKER)).toBe(false);
+ const prompt = readConnectPrompt(answer);
+ expect(prompt).toMatchObject({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ agentId: "expenses",
+ reason: "access",
+ });
+ expect(prompt?.message).toMatch(said);
+ expect(prompt?.message).toContain("Do not browse to its website");
+ },
+ );
+
+ test("an app nobody can name gets a plain sentence and no card", async () => {
+ const { tool } = toolOver(null);
+ const answer = await tool.execute({ app: "Frobnicator" });
+ expect(readConnectPrompt(answer)).toBeNull();
+ expect(answer).toContain("no app called Frobnicator");
+ });
+
+ test("arguments that name no app are refused before the store is asked", async () => {
+ const { tool, asked } = toolOver(access("ready"));
+ expect(await tool.execute({})).toBe("Name the app to show.");
+ expect(await tool.execute({ app: " " })).toBe("Name the app to show.");
+ expect(asked).toEqual([]);
+ });
+});
+
+describe("a refusal carrying a card, on its way to the model", () => {
+ async function refusedWith(message: string) {
+ const [tool] = await grantedTools({
+ store: {
+ callTool: async () => {
+ throw new PluginRefusedError(message, null);
+ },
+ listForAgent: async () => ({
+ tools: [
+ {
+ toolName: "mcp__composio-gmail__GMAIL_FETCH_EMAILS",
+ ref: "composio-gmail/GMAIL_FETCH_EMAILS",
+ description: "Fetch mail.",
+ inputSchema: { type: "object" },
+ },
+ ],
+ }),
+ } as unknown as PluginStore,
+ botId: "expenses",
+ actorId: "usr_7",
+ });
+ return (await tool?.execute({})) ?? "";
+ }
+
+ test("names the Bot that refused, so the card asks about the right one", async () => {
+ const answer = await refusedWith(
+ '[connect-app]{"serverId":"composio-gmail","title":"Gmail","reason":"shared"} Gmail is shared across this deployment and no account is connected to it yet. Ask an administrator to connect it.',
+ );
+ expect(answer.startsWith(`${REFUSAL_MARKER} `)).toBe(true);
+ expect(
+ readConnectPrompt(answer.slice(REFUSAL_MARKER.length).trim()),
+ ).toMatchObject({ agentId: "expenses", reason: "shared" });
+ });
+
+ test("leaves an ordinary refusal exactly as it was", async () => {
+ expect(await refusedWith("That tool is not granted.")).toBe(
+ `${REFUSAL_MARKER} That tool is not granted.`,
+ );
+ });
+});
diff --git a/server/tests/assertion-shape.test.ts b/server/tests/assertion-shape.test.ts
index 2c1f398017..d8e055952c 100644
--- a/server/tests/assertion-shape.test.ts
+++ b/server/tests/assertion-shape.test.ts
@@ -835,14 +835,18 @@ const ALLOW: Allowed[] = [
},
{
file: "server/tests/composio-adapter.test.ts",
- line: 5412,
+ // Moved from 5412 by OB-05, which added fifteen lines of tests above it; the offence is the same
+ // one and is re-keyed rather than re-found.
+ line: 5427,
shape: "c",
finding: "NEW (round 2 did not find this)",
why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"',
},
{
file: "server/tests/composio-adapter.test.ts",
- line: 5458,
+ // Moved from 5458 by OB-05, which added fifteen lines of tests above it; the offence is the same
+ // one and is re-keyed rather than re-found.
+ line: 5473,
shape: "c",
finding: "NEW (round 2 did not find this)",
why: 'expect( await actions.listActions("gmail", { limit: WHOLE_LISTING }), ).toEqual([ { slug: "GMAIL_FETCH_EMAILS"',
diff --git a/server/tests/composio-adapter.test.ts b/server/tests/composio-adapter.test.ts
index 19a7423bae..8649330dbf 100644
--- a/server/tests/composio-adapter.test.ts
+++ b/server/tests/composio-adapter.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { Composio, telemetry } from "@composio/core";
import {
+ BrokerAccountGoneError,
type BrokerConnection,
BrokerRefusalError,
BrokerUnconfiguredError,
@@ -3899,6 +3900,20 @@ describe("each vendor condition reaches the reader as its own remedy", () => {
});
}
+ test("an account Composio no longer holds is raised as its own class, so a call can offer Reconnect", async () => {
+ const failure = await failureOf(
+ whileRunning(raising("ComposioConnectedAccountNotFoundError")),
+ );
+ expect(failure).toBeInstanceOf(BrokerAccountGoneError);
+ // Still an authored refusal, so every reader that prefers this deployment's sentence still does.
+ expect(failure).toBeInstanceOf(BrokerRefusalError);
+ // And nothing else is: the class is what the store acts on.
+ const other = await failureOf(
+ whileRunning(raising("ComposioRequestCancelledError")),
+ );
+ expect(other).not.toBeInstanceOf(BrokerAccountGoneError);
+ });
+
/**
* ONE VENDOR CLASS OVER EVERY WAY A FETCH CAN FAIL, which is a fact about the SDK rather than a
* reading of its name. `getRawComposioToolBySlug` wraps its retrieve in a try whose catch
diff --git a/server/tests/composio-transport.test.ts b/server/tests/composio-transport.test.ts
index 9c1086a55e..022d542fa9 100644
--- a/server/tests/composio-transport.test.ts
+++ b/server/tests/composio-transport.test.ts
@@ -1,5 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test";
-import { BrokerRefusalError } from "../src/plugins/broker";
+import {
+ BrokerAccountGoneError,
+ BrokerRefusalError,
+} from "../src/plugins/broker";
import {
askAction,
type ComposioAction,
@@ -3223,6 +3226,50 @@ describe("calling one action", () => {
expect(result.text).not.toContain("Invalid request");
});
+ test("an account the vendor lost travels as a fact on the result, beside its sentence", async () => {
+ /*
+ * The one authored condition the store acts on: it turns this result into a refusal that
+ * offers Reconnect. Read off a field rather than off the wording, so rephrasing the sentence
+ * cannot quietly stop the card being drawn — and set on nothing else.
+ */
+ const authored =
+ "Composio holds no connected account for this person and gmail, so GMAIL_FETCH_EMAILS was not run.";
+ useComposioClient(
+ recording({
+ execute: async () => {
+ throw new BrokerAccountGoneError(authored);
+ },
+ }).client,
+ );
+
+ const gone = await askAction(
+ { url: "composio://gmail", actorId: "user_asker" },
+ "GMAIL_FETCH_EMAILS",
+ { __version: "20260903_00" },
+ );
+ expect(gone.result).toMatchObject({
+ isError: true,
+ text: authored,
+ accountGone: true,
+ });
+ // Not a verdict on the key: a probe meeting this keeps its "could not find out".
+ expect(gone.answered).toBe(false);
+
+ useComposioClient(
+ recording({
+ execute: async () => {
+ throw new BrokerRefusalError("Some other authored refusal.");
+ },
+ }).client,
+ );
+ const other = await callTool(
+ { url: "composio://gmail", actorId: "user_asker" },
+ "GMAIL_FETCH_EMAILS",
+ { __version: "20260903_00" },
+ );
+ expect(other.accountGone).toBeUndefined();
+ });
+
test("a failure this deployment cannot explain still reaches for the vendor's words", async () => {
// The other half of the precedence, and the reason it is `brokerSentence` rather than
// `error.message`. `./broker` raises its class only where the sentence names the step that
diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts
index 48b43149e6..978a0811d9 100644
--- a/server/tests/copilot.test.ts
+++ b/server/tests/copilot.test.ts
@@ -1069,6 +1069,53 @@ describe("what a Bot is told it holds", () => {
);
});
+ /*
+ * THE CARD TOOL. Every Bot holds `show_app_access`, and the sentences that used to end on "an
+ * administrator can grant it" now tell the Bot to call it — which puts a card with Request access
+ * in front of the person, where the sentence put the name of a role.
+ */
+ const card = { name: "show_app_access" } as never;
+
+ test("tells a Bot to show the card for a connected vendor it holds nothing for", () => {
+ const guidance = grantedToolGuidance([card], ["composio-gmail"])
+ .toLowerCase()
+ .replace(/\s+/g, " ");
+ expect(guidance).toContain("composio-gmail");
+ expect(guidance).toContain("you hold none of their tools");
+ expect(guidance).toContain("call show_app_access with its name");
+ expect(guidance).not.toContain(
+ "administrator can grant it on that connector",
+ );
+ expect(guidance).toContain("do not browse to its website");
+ });
+
+ test("tells a Bot to show the card for an app this deployment does not connect at all", () => {
+ const guidance = grantedToolGuidance([card])
+ .toLowerCase()
+ .replace(/\s+/g, " ");
+ expect(guidance).toContain("not named here at all");
+ expect(guidance).toContain("show_app_access");
+ expect(guidance).toContain("ask an administrator");
+ });
+
+ test("never lists the card tool as a system the Bot can reach", () => {
+ // Listed beside the vendors, it would read as one more system reached "as the person asking".
+ const guidance = grantedToolGuidance([...drive, card]);
+ expect(guidance).not.toMatch(/^- .*show_app_access/m);
+ expect(guidance).toContain("search_files");
+ expect(grantedToolGuidance([card])).not.toContain(
+ "You can reach these systems directly",
+ );
+ });
+
+ test("a Bot not offered the card is still told to name the administrator, as before", () => {
+ const guidance = grantedToolGuidance([], ["google-drive"])
+ .toLowerCase()
+ .replace(/\s+/g, " ");
+ expect(guidance).toContain("administrator can grant it on that connector");
+ expect(guidance).not.toContain("show_app_access");
+ });
+
test("says nothing at all when the Bot holds nothing", () => {
// A deployment with no connectors must not be told about connectors it does not have.
expect(grantedToolGuidance([])).toBe("");
diff --git a/server/tests/memory-store.integration.test.ts b/server/tests/memory-store.integration.test.ts
index 00c8079a8b..efa8acb632 100644
--- a/server/tests/memory-store.integration.test.ts
+++ b/server/tests/memory-store.integration.test.ts
@@ -1,5 +1,6 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { randomUUID } from "node:crypto";
+import { connectAppRefusal } from "@openbot/shared/connect-prompt";
import { and, eq, inArray } from "drizzle-orm";
import { createAuditStore } from "../src/audit";
import { createCredentialStore } from "../src/credentials";
@@ -11,7 +12,7 @@ import { mcpServers, mcpTools } from "../src/db/schema/plugins";
import { createMemoryIngestion } from "../src/memory/ingestion";
import { createMemoryStore } from "../src/memory/store";
import { MemoryNotFoundError, MemoryRefusedError } from "../src/memory/types";
-import { createPluginStore } from "../src/plugins/store";
+import { createPluginStore, PluginRefusedError } from "../src/plugins/store";
import { TEST_POOL, testDatabaseUrl } from "./support/database";
const database = createDatabase(testDatabaseUrl(), TEST_POOL);
@@ -25,6 +26,8 @@ const ref = `${server}/read_documents`;
let vendorText =
'[{"id":"doc1","text":"Project launch is Tuesday","title":"Roadmap"}]';
let vendorError = false;
+/** Thrown by the vendor instead of answering, when set: how a refused call reaches a sync. */
+let vendorThrows: Error | null = null;
const calls: { actorId?: string; botId?: string }[] = [];
const plugins = createPluginStore({
brokerProvider: "composio",
@@ -35,6 +38,7 @@ const plugins = createPluginStore({
policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }),
callVendor: async (connection) => {
calls.push(connection);
+ if (vendorThrows) throw vendorThrows;
return { text: vendorText, isError: vendorError };
},
});
@@ -252,6 +256,31 @@ test("vendor failure hides stale source facts and records a visible error", asyn
expect((await store.getSource(owner, value.id)).syncStatus).toBe("error");
vendorError = false;
});
+test("a refusal carrying an app card is said for the sources page, not stored raw", async () => {
+ const value = await source();
+ vendorThrows = new PluginRefusedError(
+ connectAppRefusal({
+ serverId: server,
+ title: "Docs",
+ message:
+ "Your Docs account is no longer connected at the vendor. Reconnect it and ask again.",
+ reason: "reconnect",
+ }),
+ null,
+ );
+ try {
+ await expect(ingestion.sync(owner, value.id)).rejects.toBeInstanceOf(
+ PluginRefusedError,
+ );
+ const { syncError } = await store.getSource(owner, value.id);
+ expect(syncError).toBe(
+ "Your Docs account needs reconnecting. Reconnect it under Settings, Connected accounts, then sync again.",
+ );
+ expect(syncError).not.toContain("[connect-app]");
+ } finally {
+ vendorThrows = null;
+ }
+});
test("source leases prevent competing connector calls", async () => {
const value = await source();
const results = await Promise.all([
diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts
index 01d5318d93..aa4cb17ab9 100644
--- a/server/tests/plugin-store.integration.test.ts
+++ b/server/tests/plugin-store.integration.test.ts
@@ -2809,12 +2809,29 @@ describe("a dynamic client the vendor has evicted", () => {
"invalid_grant",
);
- await expect(call()).rejects.toThrow("invalid_grant");
+ // A withdrawn grant is the person's to fix by connecting again: the refusal draws the card that
+ // says so, rather than folding the vendor's code into a tool error.
+ const refusal = await call().catch((error: unknown) => error);
+ expect(refusal).toBeInstanceOf(PluginRefusedError);
+ expect(readConnectPrompt((refusal as Error).message)).toMatchObject({
+ serverId: dynamicServerId,
+ reason: "reconnect",
+ });
- // A withdrawn grant is the person's to fix by connecting again. Minting a client for it would
- // leave a spare client behind and still refuse.
+ // Minting a client for it would leave a spare client behind and still refuse.
expect(registrations).toEqual([]);
expect(offered).toEqual([EVICTED.clientId]);
+
+ // And the connection now reads as withdrawn, which is Reconnect on every screen.
+ const [connection] = await dynamicStore.connectionsFor(dynamicUserId);
+ expect(connection).toMatchObject({
+ serverId: dynamicServerId,
+ revoked: true,
+ });
+ // The next call is refused before anything is presented to the vendor again.
+ offered.length = 0;
+ await expect(call()).rejects.toBeInstanceOf(PluginRefusedError);
+ expect(offered).toEqual([]);
});
/**
diff --git a/server/tests/shared-brokered-store.integration.test.ts b/server/tests/shared-brokered-store.integration.test.ts
index 24f609cf19..e6a2c44f84 100644
--- a/server/tests/shared-brokered-store.integration.test.ts
+++ b/server/tests/shared-brokered-store.integration.test.ts
@@ -10,7 +10,12 @@ import {
mcpServers,
mcpTools,
} from "../src/db/schema";
-import type { ConnectedAppBroker } from "../src/plugins/broker";
+import { readConnectPrompt } from "@openbot/shared/connect-prompt";
+import { auditEvents } from "../src/db/schema";
+import {
+ BrokerAccountGoneError,
+ type ConnectedAppBroker,
+} from "../src/plugins/broker";
import { useComposioClient } from "../src/plugins/composio";
import { createPluginStore } from "../src/plugins/store";
import { expectOnlyRefusal } from "./helpers/refusals";
@@ -194,6 +199,12 @@ describe("a shared app", () => {
(error: Error) => error.message,
);
expectOnlyRefusal(said, "sharedNotConnected", "Team GitHub");
+ // And it carries the card, which asks an administrator rather than offering Connect.
+ expect(readConnectPrompt(said)).toMatchObject({
+ serverId: app,
+ title: "Team GitHub",
+ reason: "shared",
+ });
});
test("offboarding the admin who connected it leaves it in place", async () => {
@@ -654,3 +665,121 @@ describe("a duplicate row at an app's url", () => {
]);
});
});
+
+/*
+ * THE ACCOUNT GONE AT THE VENDOR. Nothing on this deployment's rows knows it happened, so the call
+ * that meets it is the only place the person can be told: refused with the app card, `reconnect`
+ * for their own account and `shared` for the team's, stamped with when — and audited once, as the
+ * failure it is.
+ */
+describe("an account the vendor no longer holds", () => {
+ const gone = () =>
+ useComposioClient({
+ listActions: async () => [],
+ execute: async () => {
+ throw new BrokerAccountGoneError(
+ "Composio holds no connected account for this person and team-gh, so GITHUB_LIST_ISSUES was not run.",
+ );
+ },
+ });
+
+ /** Who still holds a row at the app, by holder. */
+ const rowsFor = async (holder: "person" | "deployment") =>
+ (
+ await database
+ .select({ userId: brokeredConnections.userId })
+ .from(brokeredConnections)
+ .where(
+ and(
+ eq(brokeredConnections.app, app),
+ eq(brokeredConnections.holder, holder),
+ ),
+ )
+ ).map((row) => row.userId);
+
+ /* Counted before and after: the trail is not cleaned between tests, which share the app id. */
+ const failures = async () =>
+ (
+ await database
+ .select({ eventType: auditEvents.eventType })
+ .from(auditEvents)
+ .where(eq(auditEvents.targetId, `${app}/GITHUB_LIST_ISSUES`))
+ ).filter((row) => row.eventType === "mcp.call_failed").length;
+
+ test("on a Shared app: refused with a card saying an administrator has to connect it again", async () => {
+ const store = freshStore();
+ await seedShared(store, true);
+ gone();
+ const already = await failures();
+ const before = Date.now();
+ const said = await store
+ .callTool({
+ ref: `${app}/GITHUB_LIST_ISSUES`,
+ args: {},
+ botId: bot,
+ actorId: asker,
+ })
+ .then(
+ () => "called",
+ (error: Error) => error.message,
+ );
+ const prompt = readConnectPrompt(said);
+ expect(prompt).toMatchObject({
+ serverId: app,
+ title: "Team GitHub",
+ reason: "shared",
+ });
+ expect(prompt?.message).toContain(
+ "An administrator has to connect it again",
+ );
+ expect(Date.parse(prompt?.at ?? "")).toBeGreaterThanOrEqual(before - 1000);
+ expect(await failures()).toBe(already + 1);
+ // The team account's row is gone with the account, so every screen reads it as not connected.
+ expect(await rowsFor("deployment")).toEqual([]);
+ });
+
+ test("on a personal app: refused with a card offering Reconnect", async () => {
+ const store = freshStore();
+ await seedShared(store, false);
+ await database
+ .update(mcpServers)
+ .set({ accountMode: "personal", sharedVendorUserId: null })
+ .where(eq(mcpServers.id, app));
+ await database.insert(brokeredConnections).values([
+ {
+ provider: "composio",
+ app,
+ holder: "person",
+ userId: asker,
+ vendorUserId: `vendor-${asker}`,
+ },
+ // Somebody else's account at the same app, which the vendor still holds.
+ {
+ provider: "composio",
+ app,
+ holder: "person",
+ userId: admin,
+ vendorUserId: `vendor-${admin}`,
+ },
+ ]);
+ gone();
+ const said = await store
+ .callTool({
+ ref: `${app}/GITHUB_LIST_ISSUES`,
+ args: {},
+ botId: bot,
+ actorId: asker,
+ })
+ .then(
+ () => "called",
+ (error: Error) => error.message,
+ );
+ expect(readConnectPrompt(said)).toMatchObject({
+ serverId: app,
+ reason: "reconnect",
+ });
+ expect(said).toContain("Reconnect it and ask again");
+ // Only the asker's row goes, so Connect on their card starts a fresh connection.
+ expect(await rowsFor("person")).toEqual([admin]);
+ });
+});
diff --git a/shared/connect-prompt.test.ts b/shared/connect-prompt.test.ts
index 630b76437c..336c53058a 100644
--- a/shared/connect-prompt.test.ts
+++ b/shared/connect-prompt.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
+ addressedTo,
CONNECT_APP_MARKER,
connectAppRefusal,
readConnectPrompt,
@@ -58,3 +59,65 @@ describe("text that is not one", () => {
expect(readConnectPrompt(undefined)).toBeNull();
});
});
+
+describe("the Bot, the reason and the moment it carries", () => {
+ test("survive the round trip, and are left out when absent", () => {
+ const text = connectAppRefusal({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ message: "Your Gmail account is no longer connected at the vendor.",
+ agentId: "expenses",
+ reason: "reconnect",
+ at: "2026-10-20T12:00:00.000Z",
+ });
+ expect(readConnectPrompt(text)).toEqual({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ message: "Your Gmail account is no longer connected at the vendor.",
+ agentId: "expenses",
+ reason: "reconnect",
+ at: "2026-10-20T12:00:00.000Z",
+ });
+ expect(connectAppRefusal({ serverId: "s", title: "T", message: "m" })).toBe(
+ `${CONNECT_APP_MARKER}{"serverId":"s","title":"T"} m`,
+ );
+ });
+
+ test("a field of the wrong shape is dropped, and the prompt still names its app", () => {
+ const prompt = readConnectPrompt(
+ `${CONNECT_APP_MARKER}{"serverId":"composio-gmail","title":"Gmail","agentId":7,"reason":"grant me","at":"yesterday"} hello`,
+ );
+ expect(prompt).toEqual({
+ serverId: "composio-gmail",
+ title: "Gmail",
+ message: "hello",
+ });
+ });
+
+ test("addressedTo names the Bot on a prompt that names none, and leaves everything else alone", () => {
+ const bare = connectAppRefusal({
+ serverId: "notion",
+ title: "Notion",
+ message: "You have not connected your Notion account.",
+ reason: "shared",
+ });
+ expect(readConnectPrompt(addressedTo(bare, "expenses"))).toEqual({
+ serverId: "notion",
+ title: "Notion",
+ message: "You have not connected your Notion account.",
+ reason: "shared",
+ agentId: "expenses",
+ });
+
+ const named = connectAppRefusal({
+ serverId: "notion",
+ title: "Notion",
+ message: "m",
+ agentId: "travel",
+ });
+ expect(addressedTo(named, "expenses")).toBe(named);
+ expect(addressedTo("That tool is not granted.", "expenses")).toBe(
+ "That tool is not granted.",
+ );
+ });
+});
diff --git a/shared/connect-prompt.ts b/shared/connect-prompt.ts
index 499ea32787..95bafbedb4 100644
--- a/shared/connect-prompt.ts
+++ b/shared/connect-prompt.ts
@@ -1,5 +1,7 @@
/**
- * The refusal a person can act on: an app this call needed, that they have not connected.
+ * The refusal a person can act on: an app this call needed, that they have not connected — or
+ * whose account the vendor has lost, or whose team account nobody has connected — and the answer of
+ * the one tool that says what an app is for them without a call having been refused at all.
*
* ONE DECLARATION, READ FROM BOTH SIDES, for the reason `./handoff-markers.ts` sets out at length.
* A tool that runs on the server reaches the transcript as text meant for a model, so a renderer
@@ -16,15 +18,65 @@
/** The first characters of the refusal, immediately followed by its JSON. */
export const CONNECT_APP_MARKER = "[connect-app]";
+/**
+ * Why the card was drawn, as the server saw it at the moment of the call.
+ *
+ * `connect` — this person has no account at the app. `reconnect` — they had one, and the vendor no
+ * longer holds it: a grant withdrawn at the provider, an account removed in Composio's dashboard.
+ * `shared` — a Shared app whose one team account is missing, which only an administrator connects.
+ * `access` — the Bot asked what the app is for this person (`show_app_access`), with no refusal
+ * behind it: switched off, not granted, or any of the above.
+ *
+ * A HINT, NOT THE STATE. The card asks the server for the app's state for this person and this Bot
+ * when it is drawn, and draws that. The one place the hint wins is an account the vendor has lost
+ * whose row still stands — a refusal from before the call that found out started deleting the row —
+ * where the live state reads `ready` and only the refusal knows otherwise. See `AppAccessCard`.
+ */
+export type ConnectReason = "connect" | "reconnect" | "shared" | "access";
+
+const REASONS: readonly ConnectReason[] = [
+ "connect",
+ "reconnect",
+ "shared",
+ "access",
+];
+
export type ConnectPrompt = {
- /** The plugin server to connect, as `POST /api/plugins/servers/:serverId/connect` names it. */
+ /**
+ * The app, as the server names it: the plugin server to connect, as
+ * `POST /api/plugins/servers/:serverId/connect` names it, or the key an app nobody enabled would
+ * have. What the card asks `GET /api/plugins/for/:agentId/apps/:app` about.
+ */
serverId: string;
/** The app's own name, for a card that says what it is asking for. */
title: string;
/** What the Bot would have said, kept intact for a reader with no card to draw. */
message: string;
+ /**
+ * The Bot whose call this was, so the card asks about the right one in a channel several Bots
+ * answer in. Absent on a refusal written before it carried one, where the card falls back to the
+ * Bot the surface declared.
+ */
+ agentId?: string;
+ /** See {@link ConnectReason}. Absent reads as `connect`, which is all a refusal used to mean. */
+ reason?: ConnectReason;
+ /**
+ * When the server refused, as an ISO instant — on a `reconnect` or `shared` refusal, the only
+ * ones whose hint can outrank the live state. A connection made after this is the reconnection,
+ * and the card stops saying Reconnect. See `AppAccess.connectedAt`.
+ */
+ at?: string;
};
+/**
+ * The name of the tool every Bot holds for saying what an app is for the person asking.
+ *
+ * Here rather than beside the tool, because the transcript reads it too: that tool's answer is the
+ * one ordinary result (not a refusal) whose marker is drawn as a card, and a renderer may only trust
+ * a marker on a result it knows this deployment wrote.
+ */
+export const SHOW_APP_ACCESS_TOOL = "show_app_access";
+
/**
* The refusal text a `PluginRefusedError` carries for an app the asker has not connected.
*
@@ -32,8 +84,28 @@ export type ConnectPrompt = {
* the MODEL is told, and because a transcript that cannot parse the marker still reads correctly.
*/
export function connectAppRefusal(prompt: ConnectPrompt): string {
- const { serverId, title, message } = prompt;
- return `${CONNECT_APP_MARKER}${JSON.stringify({ serverId, title })} ${message}`;
+ const { serverId, title, message, agentId, reason, at } = prompt;
+ return `${CONNECT_APP_MARKER}${JSON.stringify({
+ serverId,
+ title,
+ ...(agentId ? { agentId } : {}),
+ ...(reason ? { reason } : {}),
+ ...(at ? { at } : {}),
+ })} ${message}`;
+}
+
+/**
+ * The same text with the Bot named on it, for the one caller that knows which Bot asked.
+ *
+ * WHY AFTERWARDS RATHER THAN AT THE SITE THAT WROTE IT. The refusals are raised deep in resolving a
+ * credential, which is a question about a person and an app and never about a Bot — threading a Bot
+ * id down there would be a parameter nothing else reads. The tool's own wrapper knows the Bot, so it
+ * adds it on the way out. Text that is not a prompt, or already names a Bot, is returned unchanged.
+ */
+export function addressedTo(text: string, agentId: string): string {
+ const prompt = readConnectPrompt(text);
+ if (!prompt || prompt.agentId || !agentId) return text;
+ return connectAppRefusal({ ...prompt, agentId });
}
/**
@@ -53,13 +125,32 @@ export function readConnectPrompt(
try {
const parsed: unknown = JSON.parse(rest.slice(0, end + 1));
if (!parsed || typeof parsed !== "object") return null;
- const { serverId, title } = parsed as {
+ const { serverId, title, agentId, reason, at } = parsed as {
serverId?: unknown;
title?: unknown;
+ agentId?: unknown;
+ reason?: unknown;
+ at?: unknown;
};
if (typeof serverId !== "string" || typeof title !== "string") return null;
if (!serverId || !title) return null;
- return { serverId, title, message: rest.slice(end + 1).trim() };
+ /*
+ * The optional fields are dropped rather than failing the whole prompt when they are the
+ * wrong shape: without them the card still names the right app and asks about the declared Bot,
+ * which is what every refusal did before they existed.
+ */
+ return {
+ serverId,
+ title,
+ message: rest.slice(end + 1).trim(),
+ ...(typeof agentId === "string" && agentId ? { agentId } : {}),
+ ...(REASONS.includes(reason as ConnectReason)
+ ? { reason: reason as ConnectReason }
+ : {}),
+ ...(typeof at === "string" && !Number.isNaN(Date.parse(at))
+ ? { at }
+ : {}),
+ };
} catch {
return null;
}