mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Validate pooled connectors and preserve removal revisions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a913ebe4f4
commit
e15923bb83
12 files changed
+90
-30
No files matched your search
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
File renamed without changes.
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user