Validate pooled connectors and preserve removal revisions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 11:06:15 -05:00
1 parent a913ebe4f4
commit e15923bb83
12 files changed
+90 -30

No files matched your search

+4 -3
View File
@@ -64,9 +64,10 @@ Deletion uses
`expectedRevision`. Core disables and archives the virtual connection, retaining
its task pins, cursor and run records. It rejects stale edits and new allocations;
already admitted runs keep their concrete recovery evidence. Cleanup remains
available when experimental routing or the plugin is disabled. The ordinary
connector removal endpoint uses the same pool archive transaction. Ordinary
connection updates cannot bypass pool config revisions.
available when experimental routing or the plugin is disabled. The catalog captures the reviewed pool revision before showing its removal
confirmation and uses the pool endpoint. Ordinary connection update and removal
endpoints reject pools so they cannot bypass configuration revisions. Non-managers
see the permission requirement without making a denied management request.
Configuration and committed selection are recorded in activity records. Runs
record the selected member/profile and override notes in the local run log and
+2 -2
View File
@@ -84,9 +84,9 @@ export function aiConnectionRouterSlug(pluginKey: string): string {
export function aiConnectionRouterAppDefinition(pluginKey: string, descriptor: { name: string; description: string }, availability = { available: true } as { available: boolean; reason?: string }): import("./types/app-definition.js").AppDefinition {
return {
schemaVersion: 1, slug: aiConnectionRouterSlug(pluginKey), ...descriptor,
categories: ["ai"], branding: { logoUrl: "/brands/connection-pool.svg" }, urlPatterns: [],
categories: ["ai"], branding: { logoUrl: "/brands/apps/connection-pool.svg" }, urlPatterns: [],
aiConnectionRouter: { pluginKey }, availability,
methods: [{ key: "pool", purpose: "ai", transport: "runtime_auth", auth: "none", ownershipModes: ["customer"], whenToUse: "Use existing AI connections", guidanceMd: "", riskTier: "S1" }],
methods: [],
};
}
+11 -1
View File
@@ -25,7 +25,8 @@ import {
SELF_SERVE_MCP_CANDIDATES,
SELF_SERVE_MCP_RESEARCH,
} from "./self-serve-mcp-research.js";
import { appDefinitionsSchema } from "./validators/app-definition.js";
import { aiConnectionRouterAppDefinition } from "./ai-connection-router.js";
import { appDefinitionSchema, appDefinitionsSchema } from "./validators/app-definition.js";
const googleScope = (scope: string) =>
`https://www.googleapis.com/auth/${scope}`;
@@ -1064,3 +1065,12 @@ describe("tool method permission review", () => {
.toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"]);
});
});
it("validates native pool catalog entries without inventing an authentication method", () => {
const entry = aiConnectionRouterAppDefinition("example.pool", { name: "AI connection pool", description: "Use saved connections" });
expect(appDefinitionSchema.parse(entry)).toEqual(entry);
expect(entry.methods).toEqual([]);
expect(appDefinitionSchema.safeParse({ ...entry, aiConnectionRouter: undefined }).success).toBe(false);
expect(appDefinitionSchema.safeParse({ ...entry, methods: APP_STORE_DEFINITIONS[0]!.methods }).success).toBe(false);
expect(appDefinitionSchema.safeParse({ ...entry, categories: ["developer"] }).success).toBe(false);
});
@@ -7,5 +7,12 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{
},{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"});
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})});
export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",mLine truncated
export const appDefinitionSchema=z.object({aiConnectionRouter:z.object({pluginKey:z.string().min(1)}).optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
export const appDefinitionSchema=z.object({aiConnectionRouter:z.object({pluginKey:z.string().min(1)}).optional(),schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}).superRefine((value, ctx) => {
if (value.aiConnectionRouter) {
if (value.methods.length !== 0) ctx.addIssue({ code: "custom", message: "Pooled connectors use existing connections, not authentication methods", path: ["methods"] });
if (!value.categories.includes("ai")) ctx.addIssue({ code: "custom", message: "Pooled connectors must use the AI category", path: ["categories"] });
} else if (value.methods.length === 0) {
ctx.addIssue({ code: "custom", message: "Connectors require at least one connection method", path: ["methods"] });
}
});
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});
@@ -85,9 +85,11 @@ describe("durable, authorized connection routing", () => {
const pool = await makePool(); const selected = await resolve(pool.id, "catalog-removal");
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", userId: "alice", source: "local_implicit", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "owner" }] } as typeof req.actor; next(); });
app.use("/api", toolAccessRoutes(db)); app.use(errorHandler);
app.use("/api", toolAccessRoutes(db)); app.use("/api", aiConnectionRoutes(db)); app.use(errorHandler);
expect((await request(app).patch(`/api/tool-connections/${pool.id}`).send({ name: "Bypass" })).status).toBe(400);
expect((await request(app).delete(`/api/tool-connections/${pool.id}`)).status).toBe(200);
expect((await request(app).delete(`/api/tool-connections/${pool.id}`)).status).toBe(400);
expect((await request(app).delete(`/api/companies/${companyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision + 1 })).status).toBe(409);
expect((await request(app).delete(`/api/companies/${companyId}/ai-connection-pools/${pool.id}`).send({ expectedRevision: pool.revision })).status).toBe(200);
expect((await service().list(companyId)).some(row => row.id === pool.id)).toBe(false);
expect(await db.select().from(aiConnectionTaskPins).where(eq(aiConnectionTaskPins.poolId, pool.id))).toHaveLength(1);
expect(await resolve(pool.id, "catalog-removal", { persisted: selected })).toEqual(selected);
+1 -11
View File
@@ -2129,17 +2129,7 @@ function connectorEnrollmentPrincipal(req: Request): string {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
await assertToolConnectionConfigureAccess(req, existing);
if (aiConnectionRouterPluginKey(existing)) {
assertBoard(req);
if (!await isToolConnectionManager(req, existing.companyId)) throw forbidden("A connection manager must remove this pool");
const router = aiConnectionRouterService(db);
const pool = (await router.list(existing.companyId)).find(pool => pool.id === existing.id);
if (!pool) throw notFound("Connection pool not found");
await router.remove(existing.companyId, existing.id, pool.revision, getActorInfo(req).actorId);
res.json({ ...existing, status: "archived", enabled: false });
return;
}
if (aiConnectionRouterPluginKey(existing)) throw badRequest("Remove this connection pool through its revision-checked pool settings.");
const applicationBefore = await svc.getApplication(existing.applicationId);
const { connection, removal } = await svc.archiveConnection(
existing.id,
@@ -43,7 +43,7 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string
const { pushToast } = useToast();
const { setBreadcrumbs } = useBreadcrumbs();
const accountsQuery = useQuery({ queryKey: ["pool-accounts", companyId], queryFn: () => aiConnectionsApi.list(companyId) });
const poolsQuery = useQuery({ queryKey: ["ai-connection-pools", companyId], queryFn: () => aiConnectionPoolsApi.list(companyId) });
const poolsQuery = useQuery({ queryKey: ["ai-connection-pools", companyId], queryFn: () => aiConnectionPoolsApi.list(companyId), enabled: accountsQuery.data?.canManageConnections === true });
const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(companyId), queryFn: () => toolsApi.listGallery(companyId) });
const [editing, setEditing] = useState<AiConnectionPool>();
const [draft, setDraft] = useState(emptyConfig);
@@ -63,21 +63,21 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string
const canManage = Boolean(accountsQuery.data?.canManageConnections);
const disabled = busy || !canManage || unavailable;
useEffect(() => {
if (connection && !editing) {
if (connection && !editing && canManage) {
const pool = poolsQuery.data?.find(pool => pool.id === connection.id && pool.pluginKey === pluginKey);
if (pool) { setEditing(pool); setDraft(configOf(pool)); }
}
}, [poolsQuery.data, connection, editing, pluginKey]);
}, [poolsQuery.data, connection, editing, pluginKey, canManage]);
useEffect(() => {
setBreadcrumbs([{ label: "Connectors", href: "/apps" }, { label: connection ? draft.name : "Add a connection pool" }]);
return () => setBreadcrumbs([]);
}, [connection, draft.name, setBreadcrumbs]);
useEffect(() => {
if (!editing) return;
if (!editing || !canManage) return;
let alive = true;
void aiConnectionPoolsApi.inspect(companyId, editing.id).then(value => { if (alive) setInspection(value); }).catch(() => { if (alive) setInspectionError("Usage unavailable."); });
return () => { alive = false; };
}, [companyId, editing]);
}, [companyId, editing, canManage]);
async function invalidate() {
await Promise.all([
client.invalidateQueries({ queryKey: ["ai-connection-pools", companyId] }),
@@ -108,11 +108,12 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string
setDraft(value => { const members = [...value.members]; [members[index], members[index + offset]] = [members[index + offset]!, members[index]!]; return { ...value, members }; });
}
const loadError = accountsQuery.error ?? poolsQuery.error ?? galleryQuery.error;
if (accountsQuery.isSuccess && !canManage) return <p role="alert" className="text-sm text-muted-foreground">A connection manager can view and edit connection pools.</p>;
if (loadError) return <div className="space-y-4"><p role="alert" className="text-sm text-destructive">{loadError.message}</p><Button variant="outline" onClick={() => { void accountsQuery.refetch(); void poolsQuery.refetch(); void galleryQuery.refetch(); }}>Try again</Button></div>;
if (accountsQuery.isPending || poolsQuery.isPending || galleryQuery.isPending || (connection && !editing && poolsQuery.data?.some(pool => pool.id === connection.id))) return <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading connections…</p>;
if (connection && !editing) return <p role="alert">This connection pool is no longer available.</p>;
const name = entry?.name ?? "AI connection pool";
const logoEntry = entry ?? aiConnectionRouterAppDefinition(pluginKey, { name, description: "" });
const logoEntry = entry ?? aiConnectionRouterAppDefinition(pluginKey, { name, description: "Use existing AI connections." });
const orderedMembers = <ol aria-label="Connection order" className="divide-y divide-border rounded-lg border border-border">
{draft.members.map((member, index) => {
const account = accounts.find(account => account.id === member.binding.connectionId && account.grantId === member.binding.grantId);
@@ -132,7 +133,7 @@ function PoolConnector({ companyId, pluginKey, connection }: { companyId: string
</ol>;
return <div className={connection ? "space-y-6" : "mx-auto max-w-2xl"}>
{connection ? <div className="flex items-start justify-between gap-4">
<AppDetailHeader appName={draft.name} connection={connection} logoEntry={logoEntry} brandKey={logoEntry.slug} allowRemoteLogo status={editing?.enabled ? { label: "Connected", tone: "connected" } : { label: "Paused", tone: "paused" }} actionCount={null} renaming={renaming} nameDraft={nameDraft} renamePending={disabled} onNameDraftChange={setNameDraft} onRenameStart={() => { if (!disabled) { setNameDraft(draft.name); setRenaming(true); } }} onRenameCancel={() => setRenaming(false)} onRenameSubmit={value => void save({ ...draft, name: value })} />
<AppDetailHeader appName={draft.name} connection={connection} logoEntry={logoEntry} brandKey={logoEntry.slug} allowRemoteLogo canRename={!disabled} status={editing?.enabled ? { label: "Connected", tone: "connected" } : { label: "Paused", tone: "paused" }} actionCount={null} renaming={renaming} nameDraft={nameDraft} renamePending={disabled} onNameDraftChange={setNameDraft} onRenameStart={() => { if (!disabled) { setNameDraft(draft.name); setRenaming(true); } }} onRenameCancel={() => setRenaming(false)} onRenameSubmit={value => void save({ ...draft, name: value })} />
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon-sm" aria-label="Manage connection pool"><MoreHorizontal className="size-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem variant="destructive" disabled={busy || !canManage} onSelect={() => { setError(""); setRemoving(true); }}><Trash2 />Remove connection</DropdownMenuItem></DropdownMenuContent></DropdownMenu>
</div> : <StepHeader title="Add a connection pool" subtitle={step === 0 ? "Choose connections you already use." : "New tasks rotate in this order."} step="connect" activeIndex={step} labels={["Connections", "Order"]} appIdentity={{ name, logoUrl: logoEntry.branding.logoUrl }} />}
{unavailable && <p role="alert" className="text-sm text-destructive">{entry?.availability?.reason ?? "Enable the connection pool plugin in Plugins."}</p>}
@@ -165,7 +166,7 @@ function ConnectionPicker({ accounts, selected, disabled, onChange, onRefresh }:
return <div className="space-y-4">
<div className="relative"><Search className="pointer-events-none absolute left-3 top-3 size-4 text-muted-foreground" /><Input aria-label="Search connections" placeholder="Search connections…" value={search} onChange={event => setSearch(event.target.value)} className="pl-9" /></div>
<div className="max-h-80 overflow-y-auto rounded-lg border border-border divide-y divide-border">
{filtered.map(account => { const checked = selected.some(member => member.binding.connectionId === account.id); return <label key={account.grantId} className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-accent/50"><Checkbox checked={checked} disabled={disabled || (!checked && account.status !== "connected")} onCheckedChange={value => onChange(value ? [...selected, memberOf(account)] : selected.filter(member => member.binding.connectionId !== account.id))} aria-label={account.name} /><AppLogo name={AI_PROVIDERS[account.provider].name} logoUrl={AI_PROVIDERS[account.provider].logo} size={32} /><span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{account.name}</span><span className="block text-xs text-muted-foreground">{aiMethodLabel(account.provider, account.method)} · {account.ownership === "shared" ? "Shared" : "Personal"}</span></span>{account.status !== "connected" && <span className="text-xs text-muted-foreground">Needs attention</span>}</label>; })}
{filtered.map(account => { const existing = selected.find(member => member.binding.connectionId === account.id); const checked = existing?.binding.grantId === account.grantId; return <label key={account.grantId} className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-accent/50"><Checkbox checked={checked} disabled={disabled || (!checked && (account.status !== "connected" || Boolean(existing)))} onCheckedChange={value => onChange(value ? [...selected, memberOf(account)] : selected.filter(member => member.binding.connectionId !== account.id))} aria-label={account.name} /><AppLogo name={AI_PROVIDERS[account.provider].name} logoUrl={AI_PROVIDERS[account.provider].logo} size={32} /><span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{account.name}</span><span className="block text-xs text-muted-foreground">{aiMethodLabel(account.provider, account.method)} · {account.ownership === "shared" ? "Shared" : "Personal"}</span></span>{account.status !== "connected" && <span className="text-xs text-muted-foreground">Needs attention</span>}</label>; })}
{filtered.length === 0 && <p className="px-4 py-6 text-sm text-muted-foreground">{accounts.length ? "No matching connections." : "No AI connections yet."}</p>}
</div>
<div className="flex items-center justify-between gap-3 text-sm"><Link to="/apps" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground">Connect a new account<ExternalLink className="size-3.5" /></Link><Button variant="ghost" size="sm" onClick={onRefresh}>Refresh</Button></div>
+3
View File
@@ -728,6 +728,7 @@ export function AppDetailHeader({
allowRemoteLogo,
status,
actionCount,
canRename = true,
renaming,
nameDraft,
renamePending,
@@ -743,6 +744,7 @@ export function AppDetailHeader({
allowRemoteLogo: boolean;
status: StatusInfo;
actionCount: number | null;
canRename?: boolean;
renaming: boolean;
nameDraft: string;
renamePending: boolean;
@@ -794,6 +796,7 @@ export function AppDetailHeader({
size="icon"
className="h-7 w-7 text-muted-foreground"
aria-label="Rename app"
disabled={!canRename}
onClick={onRenameStart}
>
<Pencil className="h-3.5 w-3.5" />
+28 -1
View File
@@ -5,9 +5,13 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Browse } from "./Browse";
import { getAppStoreDefinition } from "@paperclipai/shared";
import { aiConnectionRouterAppDefinition, getAppStoreDefinition } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
const poolListMock = vi.hoisted(() => vi.fn());
const poolRemoveMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/ai-connection-pools", () => ({ aiConnectionPoolsApi: { list: poolListMock, remove: poolRemoveMock } }));
const listGalleryMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
@@ -649,6 +653,29 @@ describe("Connectors landing page", () => {
);
});
it("retains the confirmed pool revision when a concurrent edit rejects removal", async () => {
const app = aiConnectionRouterAppDefinition("example.pool", { name: "AI connection pool", description: "Use saved connections" });
listGalleryMock.mockResolvedValue({ apps: [app] });
listApplicationsMock.mockResolvedValue({ applications: [application({ name: app.name, metadata: { sourceTemplateKey: app.slug } })] });
listConnectionsMock.mockResolvedValue({ connections: [connection({ name: "Research", config: { aiRouter: { pluginKey: "example.pool" } } })] });
poolListMock.mockResolvedValue([{ id: "conn-notion", name: "Research", revision: 7 }]);
poolRemoveMock.mockRejectedValue(new Error("Pool changed; reload before deleting"));
await renderBrowse();
await act(() => { container.querySelector<HTMLButtonElement>('button[aria-label="Manage Research connection"]')!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); });
await flushReact();
await act(() => Array.from(document.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(item => item.textContent?.trim() === "Remove connection")!.click());
await flushReact();
expect(document.body.textContent).toContain("The connections in this pool are kept.");
poolListMock.mockResolvedValue([{ id: "conn-notion", name: "Edited elsewhere", revision: 8 }]);
await act(() => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === "Remove connection")!.click());
await flushReact();
expect(poolRemoveMock).toHaveBeenCalledWith("company-1", "conn-notion", 7);
expect(poolListMock).toHaveBeenCalledTimes(1);
expect(archiveConnectionMock).not.toHaveBeenCalled();
expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ body: "Pool changed; reload before deleting" }));
});
it("starts each AgentMail Add connection with a distinct setup identity", async () => {
chatListMock.mockResolvedValue([{ id: "chat-draft", provider: "agentmail", status: "draft", assignedAgentName: "Ralph" }]);
await renderBrowse();
+19 -1
View File
@@ -38,6 +38,7 @@ import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { aiConnectionPoolsApi } from "@/api/ai-connection-pools";
import { toolsApi } from "@/api/tools";
import { emailApi } from "@/api/email";
import {
@@ -116,6 +117,7 @@ type ConnectionRemovalTarget = {
providerName: string;
remainingConnectionCount: number;
pool?: boolean;
poolRevision?: number;
} & ({ kind: "chat"; provider: ChatProvider } | { kind?: undefined });
// Temporary, page-only hold until Google OAuth verification is approved.
@@ -351,6 +353,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
} else {
await chatEndpointsApi.setup(target.id, { action: "remove" });
}
} else if (target.pool) {
if (!target.poolRevision) throw new Error("Open the confirmation again before removing this pool.");
await aiConnectionPoolsApi.remove(selectedCompanyId!, target.id, target.poolRevision);
} else {
await toolsApi.archiveConnection(target.id);
}
@@ -389,6 +394,19 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
}),
});
async function requestRemoval(target: ConnectionRemovalTarget) {
if (!target.pool) { setConnectionToRemove(target); return; }
try {
const pool = (await aiConnectionPoolsApi.list(selectedCompanyId!)).find(pool => pool.id === target.id);
if (!pool) throw new Error("This connection pool is no longer available.");
// Capture the revision when presenting the confirmation. Never replace
// it at submission time: concurrent edits must invalidate this consent.
setConnectionToRemove({ ...target, accountName: pool.name, poolRevision: pool.revision });
} catch (error) {
pushToast({ title: "Couldn't open the connection pool", body: error instanceof Error ? error.message : "Please try again.", tone: "error" });
}
}
const gallery = (
(galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[]
).filter((entry) => {
@@ -731,7 +749,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne
row={row}
userProfileById={userProfileById}
onNavigate={navigate}
onRequestRemove={setConnectionToRemove}
onRequestRemove={target => void requestRemoval(target)}
preselectedAgentId={preselectedChatAgentId}
chatConnectorsEnabled={chatConnectorsEnabled}
/>
@@ -44,6 +44,7 @@ export function ConnectionPoolConnectorHost({ pluginKey = "example.pool-router",
if (path === `/api/companies/${companyId}/tools/connections`) return reply({ connections: connections() });
if (path === `/api/companies/${companyId}/ai-connections`) return reply({ currentUserId: "dotta", canManageConnections: scenario !== "permission", connections: accounts });
if (path === `/api/companies/${companyId}/ai-connection-pools`) {
if (scenario === "permission") return Response.json({ error: "A connection manager is required" }, { status: 403 });
if (method === "GET") return reply(pools);
if (scenario === "conflict") return Response.json({ error: "Pool changed; reload before saving" }, { status: 409 });
const previousPool = pools.find(pool => pool.id === body.id);