refactor(tools): isolate connection access and removal (#17167)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connection grants and installations control which identities and
agents can reach tools.
> - Removal must close those access paths and stop runtimes before
cleaning up owned secrets.
> - These responsibilities still shared the setup coordinator after
earlier extractions.
> - This pull request gives grants, installations, credential validation
and removal explicit owners.
> - The benefit is a smaller coordinator and focused access-lifecycle
modules with existing security ordering intact.

## Linked Issues or Issue Description

Depends on #17165. Earlier stack: #17144 → #17146 → #17147 → #17155 →
#17157 → #17159 → #17161 → #17162 → #17165 → this PR.

**What existing behavior does this improve?**

Maintenance of connection audiences, delegation, installation and
removal.

**Current behavior**

The 6,419-line service still owns grant mutation, installation reach and
resumable credential cleanup alongside app setup.

**Proposed behavior**

A grant owner manages audiences and delegation. An installation owner
manages reach and its profile bindings. A removal owner keeps access
revocation, runtime shutdown, secret classification and cleanup
together. The coordinator delegates through typed APIs.

**Reason and benefit**

The entry point falls to 4,821 lines / 198,065 bytes, meeting the
requested under-5,000-line target. Across the full stack it falls from
21,525 lines / 829,519 bytes. App setup remains together in the
coordinator.

## What Changed

- Extract credential validation (75 lines), grants (603), installations
(350) and removal (658).
- Preserve company boundaries, audience advisory locks, membership
locks, assignment locks and audit order.
- Keep the existing runtime supervisor and secret client. Retain live
HTTP options for provider shutdown.
- Keep Slack credential-mutation leasing and resumable owned-secret
cleanup with removal.

## Verification

- Removal, grant-resolution and the stronger real HTTP OAuth fixture
pass before extraction (13 cases); the existing service and publication
baselines also pass.
- The original combined targeted run passed 525 cases across seven
suites. After replaying onto master, 601 cases pass across nine suites,
including Braintrust lifecycle and connector-policy coverage, with one
database worker.
- Full repository typecheck and build passed before merge integration.
After integration, server typecheck, module-boundary and whitespace
gates pass; fresh CI covers the full repository gates.
- All 94 pre-existing function/method bodies match by normalized syntax
comparison. A full-stack TypeScript symbol audit preserves all 18
exports and 89 service methods.
- The earlier local full-suite run was interrupted after 79 minutes and
remains unverified. All current-head CI gates pass on
`d5307fcea2365e51a75d49db82deeed236d19951`, including the full sharded
test matrix, browser suites, typecheck, build and canary dry run.
Greptile is 5/5 with no open review threads after merge integration.

- GitHub canceled duplicate CI run
[39716841632](https://github.com/paperclipai/paperclip/actions/runs/39716841632)
during the atomic stack update. Replacement run
[39716843243](https://github.com/paperclipai/paperclip/actions/runs/39716843243)
passed on the same head; no failing test was retried.

## Risks

- Audience replacement must not erase an inactive member marker and
widen authority. Its advisory and membership locks remain in the grant
owner.
- Uninstall must remove only its own profile bindings. Existing
operator-authored bindings keep their current behavior.
- Removal must remain fail-closed and retryable after provider failures.
Its order, shared-secret retention, supervisor and cleanup references
are unchanged and covered by removal tests.
- No schema, public API, policy or scheduling change is intended. The
first eight PRs have landed; this PR follows the updated catalog
extraction.

## Model Used

OpenAI Codex, GPT-6 family, with repository tools and code execution.
The exact serving model ID and context-window size are not exposed in
this session.

## Checklist


- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-21 22:39:00 -05:00
1 parent f7210cd31f
commit 4dfcb4c9b8
6 files changed
+1722 -1620

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,75 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySecrets } from "@paperclipai/db";
import { CLASS3_STATIC_LEASE_ALLOWLIST } from "@paperclipai/shared";
import { unprocessable } from "../../../errors.js";
function assertClass3ToolCredentialRefAllowed(ref: {
configPath?: string | null;
projectionClass?: string | null;
projectionAllowlistKey?: string | null;
}) {
const projectionClass = ref.projectionClass ?? "unclassified";
if (projectionClass !== "class_3_static_lease") return;
if (!ref.configPath?.trim() || !ref.projectionAllowlistKey?.trim()) {
throw unprocessable(
"Class-3 static lease tool credentials require an allowlist key and config path",
{
code: "class_3_static_lease_allowlist_required",
targetType: "tool_connection",
configPath: ref.configPath ?? null,
},
);
}
const allowed = CLASS3_STATIC_LEASE_ALLOWLIST.some(
(entry) =>
entry.key === ref.projectionAllowlistKey &&
entry.targetType === "tool_connection" &&
entry.configPath === ref.configPath,
);
if (!allowed) {
throw unprocessable(
"Class-3 static lease tool credential is outside the approved allowlist",
{
code: "class_3_static_lease_not_allowed",
allowlistKey: ref.projectionAllowlistKey,
targetType: "tool_connection",
configPath: ref.configPath,
},
);
}
}
export function createConnectionCredentialValidation(db: Db) {
async function assertSecretRefs(
companyId: string,
refs: Array<{
secretId: string;
configPath?: string | null;
projectionClass?: string | null;
projectionAllowlistKey?: string | null;
}>,
) {
if (refs.length === 0) return;
for (const ref of refs) {
assertClass3ToolCredentialRefAllowed(ref);
}
const secretIds = [...new Set(refs.map((ref) => ref.secretId))];
for (const secretId of secretIds) {
const [secret] = await db
.select({ id: companySecrets.id })
.from(companySecrets)
.where(
and(
eq(companySecrets.id, secretId),
eq(companySecrets.companyId, companyId),
),
);
if (!secret)
throw unprocessable(
"Tool connection credential secrets must belong to the same company",
);
}
}
return assertSecretRefs;
}
@@ -0,0 +1,603 @@
import { toConnectionGrant } from "./projection.js";
import { actorBinding } from "../actor.js";
import { vercelConnectResourcesFor } from "../vercel-credentials.js";
import type { ActorInfo } from "../types.js";
import { oauthConfig } from "../oauth/metadata.js";
import { and, asc, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, connectionGrantMembers, connectionGrantDelegations, connectionGrants, authUsers, companyMemberships, toolAccessAuditEvents, toolConnections } from "@paperclipai/db";
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../errors.js";
import { isPaperclipCloudConnectorStrategy } from "../../paperclip-cloud-connector.js";
import { vercelTokenRequest, type VercelConnectClient } from "../../vercel-connect.js";
import type { createConnectionCredentialValidation } from "./credential-validation.js";
type GrantDependencies = {
getConnectionRow(idOrUid: string, companyId?: string): Promise<typeof toolConnections.$inferSelect>;
assertSecretRefs: ReturnType<typeof createConnectionCredentialValidation>;
vercelConnect: VercelConnectClient | null;
};
export function createConnectionGrants(db: Db, { getConnectionRow, assertSecretRefs, vercelConnect }: GrantDependencies) {
return {
listConnectionGrants: async (idOrUid: string, companyId?: string) => {
const connection = await getConnectionRow(idOrUid, companyId);
const grants = await db
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
),
)
.orderBy(
desc(connectionGrants.isDefault),
desc(connectionGrants.updatedAt),
);
const grantIds = grants.map((grant) => grant.id);
const [members, delegations] =
grantIds.length === 0
? [[], []]
: await Promise.all([
db
.select()
.from(connectionGrantMembers)
.where(
and(
eq(connectionGrantMembers.companyId, connection.companyId),
inArray(connectionGrantMembers.grantId, grantIds),
),
),
db
.select()
.from(connectionGrantDelegations)
.where(
and(
eq(
connectionGrantDelegations.companyId,
connection.companyId,
),
inArray(connectionGrantDelegations.grantId, grantIds),
),
),
]);
return {
connection: { id: connection.id, uid: connection.uid },
grants: grants.map((grant) => ({
...toConnectionGrant(grant),
members: members.filter((member) => member.grantId === grant.id),
delegations: delegations.filter(
(delegation) => delegation.grantId === grant.id,
),
})),
};
},
/**
* Company members eligible to appear in an organization grant's audience.
* The audience editor needs display names, and the client must not have to
* cross-reference a second endpoint to render "12 selected members".
*/
listConnectionAudienceMembers: async (companyId: string) => {
const rows = await db
.select({
userId: companyMemberships.principalId,
name: authUsers.name,
email: authUsers.email,
})
.from(companyMemberships)
.leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId))
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
),
);
return rows
.map((row) => ({
userId: row.userId,
name: row.name ?? null,
email: row.email ?? null,
}))
.sort((a, b) =>
(a.name ?? a.email ?? a.userId).localeCompare(
b.name ?? b.email ?? b.userId,
),
);
},
/**
* Replace an organization grant's audience atomically (PAP-17835).
*
* An empty `memberUserIds` persists as zero rows, which the resolver already
* treats as "every organization member". Replacement is delete-then-insert
* inside one transaction so a partially-applied audience can never widen or
* narrow access, and every member id is checked against active company
* membership first so an audience cannot name an outsider. Existing audience
* members are locked too: membership cleanup deliberately retains a sole
* inactive audience row, and a concurrent empty replacement must not erase
* that fail-closed marker after cleanup wins. Once cleanup has retained that
* marker, callers must restore the member or replace it with an active named
* audience before widening access to the whole company.
*/
replaceConnectionGrantMembers: async (
idOrUid: string,
grantId: string,
memberUserIds: string[],
actor?: ActorInfo,
) => {
const connection = await getConnectionRow(idOrUid);
const [grant] = await db
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.id, grantId),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
),
)
.limit(1);
if (!grant) throw notFound("Connection grant not found");
if (grant.kind !== "organization") {
throw badRequest(
"Only an organization identity has an audience; a personal identity belongs to its owner",
);
}
const requested = [
...new Set(memberUserIds.map((id) => id.trim()).filter(Boolean)),
];
const binding = actorBinding(actor);
const members = await db.transaction(async (tx) => {
// Serialize replacements before taking the current audience snapshot.
// Otherwise a second replacement could read the old rows, wait behind
// an in-flight replacement, and then miss locking a newly-added member.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${"paperclip.connection_grant_audience:" + grant.id}, 0))`);
const existingAudience = await tx
.select()
.from(connectionGrantMembers)
.where(
and(
eq(connectionGrantMembers.companyId, connection.companyId),
eq(connectionGrantMembers.grantId, grant.id),
eq(connectionGrantMembers.subjectType, "user"),
),
);
const existingUserIds = [
...new Set(existingAudience.map((row) => row.subjectId)),
];
const membershipUserIds = [
...new Set([...existingUserIds, ...requested]),
];
if (membershipUserIds.length > 0) {
// Membership suspension/archive/removal takes the same row lock before
// sweeping grant audiences. Lock both the old and new audience so an
// empty replacement also serializes with cleanup of its existing sole
// member. Whichever transaction wins is then authoritative.
const memberships = await tx
.select({
principalId: companyMemberships.principalId,
status: companyMemberships.status,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
inArray(companyMemberships.principalId, membershipUserIds),
),
)
.orderBy(asc(companyMemberships.id))
.for("update");
const active = new Set(
memberships
.filter((row) => row.status === "active")
.map((row) => row.principalId),
);
const unknown = requested.filter((id) => !active.has(id));
if (unknown.length > 0) {
throw unprocessable(
"Every audience member must be an active member of this company",
{
code: "audience_member_not_in_company",
unknownUserIds: unknown,
},
);
}
const inactiveExisting = existingUserIds.filter(
(id) => !active.has(id),
);
if (requested.length === 0 && inactiveExisting.length > 0) {
throw conflict(
"Replace inactive audience members before widening access to the whole company",
{
code: "audience_widening_blocked",
inactiveUserIds: inactiveExisting,
},
);
}
}
await tx
.select({ id: connectionGrants.id })
.from(connectionGrants)
.where(
and(
eq(connectionGrants.id, grant.id),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
),
)
.for("update");
const existingUserSet = new Set(existingUserIds);
if (requested.length === existingUserSet.size && requested.every(id => existingUserSet.has(id))) return existingAudience;
await tx
.delete(connectionGrantMembers)
.where(
and(
eq(connectionGrantMembers.companyId, connection.companyId),
eq(connectionGrantMembers.grantId, grant.id),
),
);
const inserted =
requested.length === 0
? []
: await tx
.insert(connectionGrantMembers)
.values(
requested.map((subjectId) => ({
companyId: connection.companyId,
grantId: grant.id,
subjectType: "user" as const,
subjectId,
})),
)
.returning();
await tx
.update(connectionGrants)
.set({ updatedAt: new Date() })
.where(eq(connectionGrants.id, grant.id));
return inserted;
});
await db.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
actorType: binding.actorType ?? "system",
actorId: binding.actorId,
action: "connection_grant.audience_replaced",
outcome: "success",
reasonCode: "audience_replaced",
details: {
grantId: grant.id,
memberCount: members.length,
memberUserIds: requested,
},
});
return { ...toConnectionGrant(grant), members };
},
createConnectionGrantDelegation: async (
idOrUid: string,
grantId: string,
agentId: string,
ownerUserId: string,
) => {
const connection = await getConnectionRow(idOrUid);
if (connection.connectionPurpose === "ai") {
throw badRequest("AI credentials use the connection's human access settings, not agent delegation");
}
return db.transaction(async (tx) => {
// Membership removal/suspension takes this same row lock before sweeping
// personal grants. Whichever operation wins is therefore authoritative:
// removal deletes a delegation committed first, while creation that runs
// second observes the inactive membership and fails closed.
const [membership] = await tx
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, connection.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, ownerUserId),
eq(companyMemberships.status, "active"),
),
)
.for("update")
.limit(1);
if (!membership) {
throw forbidden(
"Only an active company member can delegate their personal grant",
);
}
const [grant] = await tx
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.id, grantId),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, ownerUserId),
eq(connectionGrants.status, "active"),
),
)
.for("update")
.limit(1);
if (!grant)
throw forbidden(
"Only the active personal grant owner can create a delegation",
);
await tx.select({ id: toolConnections.id }).from(toolConnections)
.where(eq(toolConnections.id, connection.id)).for("key share");
const [targetAgent] = await tx
.select({ id: agents.id })
.from(agents)
.where(
and(
eq(agents.id, agentId),
eq(agents.companyId, connection.companyId),
),
)
.for("no key update")
.limit(1);
if (!targetAgent) throw notFound("Agent not found");
const [existing] = await tx
.select()
.from(connectionGrantDelegations)
.where(
and(
eq(connectionGrantDelegations.grantId, grant.id),
eq(connectionGrantDelegations.agentId, agentId),
),
)
.limit(1);
if (existing) return existing;
const [delegation] = await tx
.insert(connectionGrantDelegations)
.values({
companyId: connection.companyId,
grantId: grant.id,
agentId,
createdByUserId: ownerUserId,
})
.returning();
await tx.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
actorType: "user",
actorId: ownerUserId,
action: "connection_grant.delegated",
outcome: "success",
reasonCode: "delegation_created",
details: { grantId: grant.id, delegationId: delegation!.id, agentId },
});
return delegation!;
});
},
revokeConnectionGrantDelegation: async (
idOrUid: string,
grantId: string,
delegationId: string,
actor?: ActorInfo,
) => {
const connection = await getConnectionRow(idOrUid);
const delegation = await db.transaction(async tx => {
const [grant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants)
.where(and(eq(connectionGrants.id, grantId), eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id)))
.for("update");
if (!grant) return undefined;
const [target] = await tx.select({ agentId: connectionGrantDelegations.agentId }).from(connectionGrantDelegations)
.where(and(eq(connectionGrantDelegations.id, delegationId), eq(connectionGrantDelegations.companyId, connection.companyId), eq(connectionGrantDelegations.grantId, grantId)));
if (!target) return undefined;
await tx.select({ id: agents.id }).from(agents)
.where(and(eq(agents.companyId, connection.companyId), eq(agents.id, target.agentId))).for("no key update");
const [removed] = await tx
.delete(connectionGrantDelegations)
.where(
and(
eq(connectionGrantDelegations.id, delegationId),
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.grantId, grantId),
),
)
.returning();
return removed;
});
if (!delegation) throw notFound("Connection grant delegation not found");
const binding = actorBinding(actor);
await db.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
actorType: binding.actorType ?? "system",
actorId: binding.actorId,
action: "connection_grant.delegation_revoked",
outcome: "success",
reasonCode: "delegation_revoked",
details: { grantId, delegationId, agentId: delegation.agentId },
});
return delegation;
},
addConnectionInstallation: async (
idOrUid: string,
input: {
providerTenant?: { name?: string; externalId?: string };
credentialSecretRefs?: typeof connectionGrants.$inferInsert.credentialSecretRefs;
isDefault?: boolean;
},
actor?: ActorInfo,
) => {
const connection = await getConnectionRow(idOrUid);
await assertSecretRefs(
connection.companyId,
input.credentialSecretRefs ?? [],
);
if (input.isDefault) {
await db
.update(connectionGrants)
.set({ isDefault: false, updatedAt: new Date() })
.where(
and(
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.kind, "organization"),
),
);
}
const binding = actorBinding(actor);
const [grant] = await db
.insert(connectionGrants)
.values({
companyId: connection.companyId,
connectionId: connection.id,
kind: "organization",
providerTenant: input.providerTenant,
credentialSecretRefs: input.credentialSecretRefs ?? [],
status: "active",
isDefault: input.isDefault ?? false,
createdByAgentId:
binding.actorType === "agent" ? binding.actorId : null,
createdByUserId:
binding.actorType === "user" ? binding.actorId : null,
})
.returning();
if (!grant) throw new Error("Failed to create connection installation");
await db.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
actorType: binding.actorType ?? "system",
actorId: binding.actorId,
action: "connection_grant.created",
outcome: "success",
reasonCode: "grant_created",
details: {
grantId: grant.id,
kind: grant.kind,
isDefault: grant.isDefault,
},
});
return toConnectionGrant(grant);
},
revokeConnectionGrant: async (
idOrUid: string,
grantId: string,
actor?: ActorInfo,
) => {
const connection = await getConnectionRow(idOrUid);
const binding = actorBinding(actor);
const [currentGrant] = await db
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.id, grantId),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
),
)
.limit(1);
if (!currentGrant) throw notFound("Connection grant not found");
let providerRevocation = "not_applicable";
if (
connection.credentialSource === "vercel_connect" &&
connection.externalCredential
) {
const request = vercelTokenRequest({
credential: connection.externalCredential,
grant: currentGrant,
connectionId: connection.id,
companyId: connection.companyId,
resources: vercelConnectResourcesFor(connection),
});
vercelConnect?.evict(request);
if (currentGrant.externalCredential?.subjectType === "app") {
providerRevocation = "manage_in_vercel";
} else if (currentGrant.externalCredential?.subjectType === "user") {
try {
if (!vercelConnect) throw new Error("Vercel Connect unavailable");
await vercelConnect.revoke(request);
providerRevocation = "success";
} catch {
providerRevocation = "failed";
}
}
} else if (
isPaperclipCloudConnectorStrategy(oauthConfig(connection).strategy)
) {
// Google revocation is client-wide for a user. The managed Workspace
// profiles intentionally share one Paperclip-owned client, so revoking
// one token here could invalidate unrelated Gmail, Drive, and Calendar
// grants. A per-profile removal is therefore local-only. A future
// provider-level disconnect must warn that it removes every profile.
providerRevocation = "local_only_shared_client";
}
const grant = await db.transaction(async (tx) => {
const [lockedGrant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants)
.where(and(eq(connectionGrants.id, grantId), eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id)))
.for("update");
if (!lockedGrant) throw notFound("Connection grant not found");
await tx.select({ id: agents.id }).from(agents)
.where(and(eq(agents.companyId, connection.companyId), notInArray(agents.status, ["pending_approval", "terminated"])))
.orderBy(agents.id).for("no key update");
const removedDelegations = await tx
.delete(connectionGrantDelegations)
.where(
and(
eq(connectionGrantDelegations.companyId, connection.companyId),
eq(connectionGrantDelegations.grantId, grantId),
),
)
.returning();
const [updated] = await tx
.update(connectionGrants)
.set({
status: "revoked",
isDefault: false,
revokedAt: new Date(),
revokedByAgentId:
binding.actorType === "agent" ? binding.actorId : null,
revokedByUserId:
binding.actorType === "user" ? binding.actorId : null,
updatedAt: new Date(),
})
.where(
and(
eq(connectionGrants.id, grantId),
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
),
)
.returning();
if (!updated) throw notFound("Connection grant not found");
if (removedDelegations.length > 0) {
await tx.insert(toolAccessAuditEvents).values(
removedDelegations.map((delegation) => ({
companyId: connection.companyId,
connectionId: connection.id,
actorType: binding.actorType ?? "system",
actorId: binding.actorId,
action: "connection_grant.delegation_revoked",
outcome: "success",
reasonCode: "grant_revoked",
details: {
grantId,
delegationId: delegation.id,
agentId: delegation.agentId,
},
})),
);
}
return updated;
});
if (!grant) throw notFound("Connection grant not found");
await db.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
actorType: binding.actorType ?? "system",
actorId: binding.actorId,
action: "connection_grant.revoked",
outcome: "success",
reasonCode: "grant_revoked",
details: { grantId: grant.id, kind: grant.kind, providerRevocation },
});
return toConnectionGrant(grant);
}
};
}
@@ -0,0 +1,350 @@
import { toConnection, toConnectionInstall } from "./projection.js";
import { actorBinding } from "../actor.js";
import { lockToolAssignmentAgents, lockToolConnectionRuntimeInputs } from "../assignment-locks.js";
import type { ActorInfo } from "../types.js";
import { and, asc, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { toolAccessAuditEvents, toolConnectionInstalls, toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
import type { ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, PutToolConnectionInstalls } from "@paperclipai/shared";
import { unprocessable } from "../../../errors.js";
import { logActivity } from "../../activity-log.js";
type InstallDependencies = {
now: () => Date;
getConnectionRow(idOrUid: string, companyId?: string): Promise<typeof toolConnections.$inferSelect>;
assertOptionalAgent(companyId: string, agentId: string | null | undefined, label: string): Promise<void>;
};
export function createConnectionInstalls(db: Db, { now, getConnectionRow, assertOptionalAgent }: InstallDependencies) {
async function appProfileForConnection(
dbClient: Pick<Db, "select" | "insert" | "delete">,
connection: typeof toolConnections.$inferSelect,
) {
const profileKey = `app:${connection.id}`;
let [profile] = await dbClient
.select()
.from(toolProfiles)
.where(
and(
eq(toolProfiles.companyId, connection.companyId),
eq(toolProfiles.profileKey, profileKey),
),
)
.limit(1);
if (!profile) {
const [sameName] = await dbClient
.select({ id: toolProfiles.id })
.from(toolProfiles)
.where(
and(
eq(toolProfiles.companyId, connection.companyId),
eq(toolProfiles.name, connection.name),
),
)
.limit(1);
const profileName = sameName
? `${connection.name} (${connection.id.replace(/-/g, "").slice(0, 8)})`
: connection.name;
[profile] = await dbClient
.insert(toolProfiles)
.values({
companyId: connection.companyId,
profileKey,
name: profileName,
description: `Access profile for ${connection.name}.`,
status: "active",
defaultAction: "deny",
metadata: {
source: "tool_connection_install",
connectionId: connection.id,
},
})
.returning();
}
// Installation controls where a connection is exposed, not which actions
// it grants. The app wizard's catalog-entry includes are the authority for
// action selection, so remove the legacy connection-wide include that used
// to silently turn every installed action on.
await dbClient
.delete(toolProfileEntries)
.where(
and(
eq(toolProfileEntries.companyId, connection.companyId),
eq(toolProfileEntries.profileId, profile.id),
eq(toolProfileEntries.selectorType, "connection"),
eq(toolProfileEntries.effect, "include"),
eq(toolProfileEntries.connectionId, connection.id),
),
);
return profile;
}
async function listConnectionInstalls(
connectionId: string,
companyId?: string,
): Promise<ToolConnectionInstall[]> {
const connection = await getConnectionRow(connectionId, companyId);
const rows = await db
.select()
.from(toolConnectionInstalls)
.where(
and(
eq(toolConnectionInstalls.companyId, connection.companyId),
eq(toolConnectionInstalls.connectionId, connection.id),
),
)
.orderBy(
asc(toolConnectionInstalls.targetType),
asc(toolConnectionInstalls.targetId),
);
return rows.map(toConnectionInstall);
}
async function resolveInstalledConnectionsForAgent(
companyId: string,
agentId: string,
): Promise<ToolConnection[]> {
await assertOptionalAgent(
companyId,
agentId,
"Tool connection install agent",
);
const installRows = await db
.select()
.from(toolConnectionInstalls)
.where(
and(
eq(toolConnectionInstalls.companyId, companyId),
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${agentId}))`,
),
);
if (installRows.length === 0) return [];
const connectionIds = [
...new Set(installRows.map((install) => install.connectionId)),
];
const rows = await db
.select()
.from(toolConnections)
.where(
and(
eq(toolConnections.companyId, companyId),
inArray(toolConnections.id, connectionIds),
),
)
.orderBy(asc(toolConnections.name));
return rows.map((row) => ({
...toConnection(row),
installs: installRows
.filter((install) => install.connectionId === row.id)
.map(toConnectionInstall),
}));
}
return {
resolveInstalledConnectionsForAgent,
listConnectionInstalls,
putConnectionInstalls: async (
connectionId: string,
input: PutToolConnectionInstalls,
actor?: ActorInfo,
): Promise<ToolConnectionInstallSnapshot> => {
const connection = await getConnectionRow(connectionId);
const requested = new Map(
input.installs.map((install) => [
`${install.targetType}:${install.targetId}`,
install,
]),
);
for (const install of requested.values()) {
if (install.targetType === "company") {
if (install.targetId !== connection.companyId)
throw unprocessable(
"Company installs must target the connection company",
);
} else {
await assertOptionalAgent(
connection.companyId,
install.targetId,
"Tool connection install agent",
);
}
}
const markMcpAccessConfigured = connection.transport === "mcp_remote"
&& connection.config.mcpAgentAccessConfigured !== true;
const accessExtensions: Array<{
targetType: "company" | "agent";
targetId: string;
profileId: string;
}> = [];
await db.transaction(async (tx) => {
await lockToolConnectionRuntimeInputs(tx, connection.companyId, connection.id);
if (markMcpAccessConfigured) {
// Preserve an explicit zero-agent selection through OAuth. Merge the
// marker in SQL so concurrent credential/config changes are retained.
await tx.update(toolConnections).set({
config: sql`${toolConnections.config} || '{"mcpAgentAccessConfigured":true}'::jsonb`,
transportConfig: sql`${toolConnections.transportConfig} || '{"mcpAgentAccessConfigured":true}'::jsonb`,
updatedAt: now(),
}).where(and(
eq(toolConnections.companyId, connection.companyId),
eq(toolConnections.id, connection.id),
));
}
const existing = await tx
.select()
.from(toolConnectionInstalls)
.where(
and(
eq(toolConnectionInstalls.companyId, connection.companyId),
eq(toolConnectionInstalls.connectionId, connection.id),
),
);
const existingKeys = new Set(
existing.map(
(install) => `${install.targetType}:${install.targetId}`,
),
);
const bindings = await tx.select({ targetType: toolProfileBindings.targetType, targetId: toolProfileBindings.targetId })
.from(toolProfileBindings).innerJoin(toolProfiles, eq(toolProfiles.id, toolProfileBindings.profileId))
.where(and(eq(toolProfileBindings.companyId, connection.companyId), eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.profileKey, `app:${connection.id}`)));
await lockToolAssignmentAgents(tx, connection.companyId, [...existing, ...requested.values(), ...bindings]);
const removals = existing.filter(
(install) =>
!requested.has(`${install.targetType}:${install.targetId}`),
);
const removeIds = removals.map((install) => install.id);
if (removeIds.length > 0) {
await tx
.delete(toolConnectionInstalls)
.where(inArray(toolConnectionInstalls.id, removeIds));
// Uninstalling must also drop the binding this path created. Installing
// writes both an install row and a profile binding, so deleting only the
// install row leaves a binding that no surface can see or remove. The
// install row is the reach gate (`mintConnectionTokenForAgent` fails with
// `installation_required`, and the heartbeat only hands over installed
// connections), so a stale binding grants no reach on its own — but it
// still makes `finishApp`, which rebuilds `access` from the bindings,
// read a target the operator already removed.
//
// Only bindings tagged `source: "tool_connection_install"` are removed.
// A binding the operator authored through the access model carries a
// different source and must survive an uninstall.
const [installProfile] = await tx
.select({ id: toolProfiles.id })
.from(toolProfiles)
.where(
and(
eq(toolProfiles.companyId, connection.companyId),
eq(toolProfiles.profileKey, `app:${connection.id}`),
),
)
.limit(1);
if (installProfile) {
for (const install of removals) {
await tx
.delete(toolProfileBindings)
.where(
and(
eq(toolProfileBindings.companyId, connection.companyId),
eq(toolProfileBindings.profileId, installProfile.id),
eq(toolProfileBindings.targetType, install.targetType),
eq(toolProfileBindings.targetId, install.targetId),
sql`${toolProfileBindings.metadata}->>'source' = 'tool_connection_install'`,
),
);
}
}
}
const additions = [...requested.entries()]
.filter(([key]) => !existingKeys.has(key))
.map(([, install]) => install);
if (additions.length > 0) {
await tx.insert(toolConnectionInstalls).values(
additions.map((install) => ({
companyId: connection.companyId,
connectionId: connection.id,
targetType: install.targetType,
targetId: install.targetId,
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
})),
);
}
if (requested.size > 0 && connection.connectionPurpose !== "ai") {
const profile = await appProfileForConnection(tx, connection);
for (const install of requested.values()) {
const [binding] = await tx
.insert(toolProfileBindings)
.values({
companyId: connection.companyId,
profileId: profile.id,
targetType: install.targetType,
targetId: install.targetId,
priority: 100,
metadata: {
source: "tool_connection_install",
connectionId: connection.id,
},
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
})
.onConflictDoNothing()
.returning({ id: toolProfileBindings.id });
if (binding)
accessExtensions.push({
targetType: install.targetType,
targetId: install.targetId,
profileId: profile.id,
});
}
}
if (markMcpAccessConfigured || removeIds.length > 0 || additions.length > 0) {
const binding = actorBinding(actor);
await tx.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
connectionId: connection.id,
actorType: binding.actorType ?? "system",
actorId: binding.actorId,
action: "connection_installs.changed",
outcome: "success",
reasonCode: "installs_changed",
details: {
...(markMcpAccessConfigured ? { mcpAgentAccessConfigured: true } : {}),
added: additions.map((install) => ({
targetType: install.targetType,
targetId: install.targetId,
})),
removed: existing
.filter((install) => removeIds.includes(install.id))
.map((install) => ({
targetType: install.targetType,
targetId: install.targetId,
})),
},
});
}
});
for (const extension of accessExtensions) {
await logActivity(db, {
companyId: connection.companyId,
actorType: actor?.actorType ?? "system",
actorId: actor?.actorId ?? "system",
action: "tool_connection.install_access_extended",
entityType: "tool_connection",
entityId: connection.id,
details: extension,
});
}
return {
connectionId: connection.id,
installs: await listConnectionInstalls(
connection.id,
connection.companyId,
),
};
}
};
}
@@ -0,0 +1,658 @@
import { toConnection } from "./projection.js";
import { actorBinding } from "../actor.js";
import { vercelConnectResourcesFor } from "../vercel-credentials.js";
import type { ActorInfo } from "../types.js";
import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js";
import { removeSlackRegistration } from "../../chat-slack-registration-cleanup.js";
import { chatEndpoints } from "@paperclipai/db";
import { isBrowserUseConnection } from "../../browser-use-client.js";
import { browserUseService } from "../../browser-use.js";
import { emitConnectionUpdated } from "../../connector-telemetry.js";
import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { connectionGrants, connectionTokenIssuances, companySecretBindings, companySecrets, userSecretDefinitions, userSecretDeclarations, toolApplications, toolCatalogEntries, toolConnectionInstalls, toolConnections, toolOauthStates, toolGatewaySessions, toolMcpGateways, toolMcpGatewayTokens, toolProfileBindings, toolProfileEntries, toolProfiles, toolRuntimeSlots } from "@paperclipai/db";
import type { ToolConnectionRemovalResult, ToolConnectionRemovalSummary } from "@paperclipai/shared";
import { notFound } from "../../../errors.js";
import { logger } from "../../../middleware/logger.js";
import { forgetMcpHttpSessions } from "../../mcp-http.js";
import { secretService } from "../../secrets.js";
import { connectionSecretsUsedByOtherConsumers } from "../../connection-credentials.js";
import { createToolRuntimeSupervisor } from "../../tool-runtime-supervisor.js";
import { vercelConnectIntegrationStatus, vercelTokenRequest, type VercelConnectClient } from "../../vercel-connect.js";
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value))
return value as Record<string, unknown>;
return {};
}
/**
* The key namespace `connectGalleryApp`, `reconnectGalleryApp` and
* `createOrRotateOAuthSecret` mint for credentials a connection owns outright.
* Nothing else writes this prefix, which is what lets removal tell a dedicated
* app credential apart from a secret the operator manages by hand — see
* `classifyConnectionSecrets`. Matched as a prefix on purpose: `secrets.remove`
* suffixes `__deleted__<id>` onto the key before it deletes the row, so a
* removal that is retried after a provider failure must still recognise it.
*/
const CONNECTION_OWNED_SECRET_KEY_PREFIX = "tool_app.";
/** Token fields a legacy row may have inlined into `config.oauth`. */
const INLINE_OAUTH_TOKEN_FIELDS = [
"access_token",
"refresh_token",
"accessToken",
"refreshToken",
"client_secret",
"clientSecret",
];
/**
* Drop inline OAuth token material from a connection config, keeping the
* non-secret identity (client id, issuer, registration source) a later
* reconnect reuses. Current code stores tokens as secret refs, never in the
* config; this exists so a row written by an older build cannot keep a usable
* token after the operator removed the app.
*/
function withoutInlineOAuthTokens(
config: Record<string, unknown>,
): Record<string, unknown> {
const oauth = config.oauth;
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth))
return config;
const next = { ...(oauth as Record<string, unknown>) };
let changed = false;
for (const field of INLINE_OAUTH_TOKEN_FIELDS) {
if (field in next) {
delete next[field];
changed = true;
}
}
return changed ? { ...config, oauth: next } : config;
}
type RemovalDependencies = {
options: { remoteHttpRequest?: (url: string, init: RequestInit) => Promise<Response> };
secrets: ReturnType<typeof secretService>;
runtimeSupervisor: Pick<ReturnType<typeof createToolRuntimeSupervisor>, "stopSlot">;
vercelConnect: VercelConnectClient | null;
getConnectionRow(idOrUid: string, companyId?: string): Promise<typeof toolConnections.$inferSelect>;
};
export function createConnectionRemoval(db: Db, { options, secrets, runtimeSupervisor, vercelConnect, getConnectionRow }: RemovalDependencies) {
/**
* Split the secrets a connection points at into the ones it owns outright and
* the ones another consumer still depends on.
*
* Removing an app is a credential revocation boundary (PAP-17119), but it must
* never destroy a secret the operator manages by hand or shares with another
* target. Two independent tests have to agree before a secret is destroyed:
*
* 1. Provenance — the key sits in the `tool_app.` namespace only the
* connect/reconnect/OAuth paths mint, or its user-secret definition does.
* Personal definitions must also have no declarations on other targets.
* 2. Exclusivity — nothing outside this connection references it: no
* `company_secret_bindings` row from another target, and no other
* connection or connection grant naming the same secret id.
*
* A secret failing either test is reported as retained; removal still drops
* this connection's binding and ref, so the connection loses the credential
* either way. Retaining a secret nobody can reach is a leak of an unused row;
* deleting one another target still resolves is an outage, so the ambiguous
* case fails towards retention.
*/
async function classifyConnectionSecrets(
connection: typeof toolConnections.$inferSelect,
secretIds: string[],
): Promise<{ owned: string[]; retained: string[] }> {
const unique = [
...new Set(
secretIds.filter((id) => typeof id === "string" && id.length > 0),
),
];
if (unique.length === 0) return { owned: [], retained: [] };
const secretRows = await db
.select({
id: companySecrets.id,
key: companySecrets.key,
scope: companySecrets.scope,
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
})
.from(companySecrets)
.where(
and(
eq(companySecrets.companyId, connection.companyId),
inArray(companySecrets.id, unique),
),
);
const byId = new Map(secretRows.map((row) => [row.id, row]));
const referencedElsewhere = await connectionSecretsUsedByOtherConsumers(db, unique);
const foreignBindings = await db
.select({ secretId: companySecretBindings.secretId })
.from(companySecretBindings)
.where(
and(
eq(companySecretBindings.companyId, connection.companyId),
inArray(companySecretBindings.secretId, unique),
sql`not (${companySecretBindings.targetType} = 'tool_connection' and ${companySecretBindings.targetId} = ${connection.id})`,
),
);
for (const row of foreignBindings) referencedElsewhere.add(row.secretId);
// Bindings are the authority, but read the sibling refs too: a row written
// before `syncCredentialBindings` existed — or by hand — can reference a
// secret with no binding to prove it.
const siblingConnections = await db
.select({
credentialRefs: toolConnections.credentialRefs,
credentialSecretRefs: toolConnections.credentialSecretRefs,
})
.from(toolConnections)
.where(
and(
eq(toolConnections.companyId, connection.companyId),
ne(toolConnections.id, connection.id),
),
);
for (const row of siblingConnections) {
for (const ref of row.credentialRefs ?? [])
referencedElsewhere.add(ref.secretId);
for (const ref of row.credentialSecretRefs ?? [])
referencedElsewhere.add(ref.secretId);
}
const siblingGrants = await db
.select({ credentialSecretRefs: connectionGrants.credentialSecretRefs })
.from(connectionGrants)
.where(
and(
eq(connectionGrants.companyId, connection.companyId),
ne(connectionGrants.connectionId, connection.id),
),
);
for (const row of siblingGrants) {
for (const ref of row.credentialSecretRefs ?? [])
referencedElsewhere.add(ref.secretId);
}
const definitionIds = secretRows.flatMap((row) => row.userSecretDefinitionId ? [row.userSecretDefinitionId] : []);
const definitions = definitionIds.length ? await db.select().from(userSecretDefinitions)
.where(and(eq(userSecretDefinitions.companyId, connection.companyId), inArray(userSecretDefinitions.id, definitionIds))) : [];
const dedicatedDefinitions = new Set(definitions.filter((row) => row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX) || row.key.startsWith(`tool_oauth.${connection.id}.`)).map((row) => row.id));
const foreignDeclarations = definitionIds.length ? await db.select().from(userSecretDeclarations).where(and(
inArray(userSecretDeclarations.userSecretDefinitionId, definitionIds),
sql`not (${userSecretDeclarations.targetType} = 'tool_connection' and ${userSecretDeclarations.targetId} = ${connection.id})`,
)) : [];
const sharedDefinitions = new Set(foreignDeclarations.map((row) => row.userSecretDefinitionId));
const owned: string[] = [];
const retained: string[] = [];
for (const secretId of unique) {
const row = byId.get(secretId);
// No row means an earlier pass of this same removal already deleted it.
// Hand it back as owned so a retry re-runs the (idempotent) revocation
// instead of reporting a credential this connection never shared.
if (!row) {
owned.push(secretId);
continue;
}
const dedicated = row.scope === "user" && row.userSecretDefinitionId
? dedicatedDefinitions.has(row.userSecretDefinitionId) && !sharedDefinitions.has(row.userSecretDefinitionId)
: row.scope === "company" && row.userSecretDefinitionId === null
&& row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX);
if (dedicated && !referencedElsewhere.has(secretId)) owned.push(secretId);
else retained.push(secretId);
}
return { owned, retained };
}
/**
* Remove an app: a credential-revoking teardown, not a status flip (PAP-17119).
*
* Order is the security property. Every database-side access path closes
* first — grants, installs, the app-managed profile, gateway tokens minted
* against it, outstanding OAuth state, the catalog, and the connection itself
* — so the app is already undispatchable before the first call out to a secret
* provider. Secret revocation runs last, and each secret's ref survives until
* that secret is gone, so a provider that errors leaves the operation failed
* closed and resumable: the credential is already unresolvable (its row is
* marked deleted first), and retrying the same removal finishes the job.
*
* What stays behind is deliberate: the connection and application rows, their
* ids, names and activity keep working so a later reconnect reuses the same
* identity — but with no credential, no install and no profile, so
* reconnecting has to ask for fresh authentication and rebuild access.
*/
async function removeConnection(
connectionId: string,
companyId?: string,
actor?: ActorInfo,
): Promise<ToolConnectionRemovalResult> {
const connection = await getConnectionRow(connectionId, companyId);
const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.connectionId, connection.id), eq(chatEndpoints.companyId, connection.companyId), eq(chatEndpoints.provider, "slack")));
if (!endpoint) return removeConnectionUnlocked(connectionId, companyId, actor);
return chatCredentialMutationLease(db)(endpoint, async lease => {
await db.transaction(async tx => {
await lease.assertOwned(tx);
await tx.update(chatEndpoints).set({ status: "archived", updatedAt: new Date() }).where(eq(chatEndpoints.id, endpoint.id));
await tx.update(toolConnections).set({ status: "archived", enabled: false, updatedAt: new Date() }).where(eq(toolConnections.id, connection.id));
});
await removeSlackRegistration(db, endpoint.id, lease);
return removeConnectionUnlocked(connectionId, companyId, actor);
});
}
async function removeConnectionUnlocked(connectionId: string, companyId?: string, actor?: ActorInfo): Promise<ToolConnectionRemovalResult> {
const connection = await getConnectionRow(connectionId, companyId);
forgetMcpHttpSessions(connection.id);
const now = new Date();
const binding = actorBinding(actor);
// Grants are read before they are revoked: a retried removal must still see
// the credential refs of a grant an earlier pass already marked revoked.
const grantRows = await db
.select({
id: connectionGrants.id,
status: connectionGrants.status,
kind: connectionGrants.kind,
subjectUserId: connectionGrants.subjectUserId,
credentialSecretRefs: connectionGrants.credentialSecretRefs,
externalCredential: connectionGrants.externalCredential,
})
.from(connectionGrants)
.where(
and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
),
);
const grantsToRevoke = grantRows.filter((row) => row.status !== "revoked");
if (grantsToRevoke.length > 0) {
await db
.update(connectionGrants)
.set({
status: "revoked",
isDefault: false,
revokedAt: now,
revokedByAgentId:
binding.actorType === "agent" ? binding.actorId : null,
revokedByUserId:
binding.actorType === "user" ? binding.actorId : null,
updatedAt: now,
})
.where(
inArray(
connectionGrants.id,
grantsToRevoke.map((row) => row.id),
),
);
}
let externalCredentialCleanup: ToolConnectionRemovalSummary["externalCredentialCleanup"] =
null;
if (
connection.credentialSource === "vercel_connect" &&
connection.externalCredential
) {
let attempted = 0;
let revoked = 0;
let failures = 0;
let appSubjectCleanup: "not_applicable" | "manage_in_vercel" =
"not_applicable";
for (const grant of grantRows) {
const request = vercelTokenRequest({
credential: connection.externalCredential,
grant,
connectionId: connection.id,
companyId: connection.companyId,
resources: vercelConnectResourcesFor(connection),
});
vercelConnect?.evict(request);
if (grant.externalCredential?.subjectType === "app") {
appSubjectCleanup = "manage_in_vercel";
continue;
}
if (grant.externalCredential?.subjectType !== "user") continue;
attempted += 1;
try {
if (!vercelConnect) throw new Error("Vercel Connect unavailable");
await vercelConnect.revoke(request);
revoked += 1;
} catch {
failures += 1;
}
}
externalCredentialCleanup = {
provider: "vercel_connect",
userSubjectsAttempted: attempted,
userSubjectsRevoked: revoked,
userSubjectFailures: failures,
appSubjectCleanup,
manageUrl: vercelConnectIntegrationStatus().manageUrl,
};
}
// Bindings are how a credential reaches a runtime, so they go before the
// provider round-trip rather than after it. They are also not needed to
// finish the job: the refs on the connection row are what a resumed removal
// reads to find the secrets it still owes a revocation.
const removedSecretBindings = await db
.delete(companySecretBindings)
.where(
and(
eq(companySecretBindings.companyId, connection.companyId),
eq(companySecretBindings.targetType, "tool_connection"),
eq(companySecretBindings.targetId, connection.id),
),
)
.returning({ id: companySecretBindings.id });
const removedInstalls = await db
.delete(toolConnectionInstalls)
.where(
and(
eq(toolConnectionInstalls.companyId, connection.companyId),
eq(toolConnectionInstalls.connectionId, connection.id),
),
)
.returning({ id: toolConnectionInstalls.id });
// The app-managed profile exists only to carry this connection's action
// selection, so it goes with the connection. Operator-authored profiles that
// happen to mention the connection are left alone — the archived connection
// is denied by the policy engine regardless.
const [appProfile] = await db
.select({ id: toolProfiles.id })
.from(toolProfiles)
.where(
and(
eq(toolProfiles.companyId, connection.companyId),
eq(toolProfiles.profileKey, `app:${connection.id}`),
),
)
.limit(1);
let appProfileOutcome: ToolConnectionRemovalSummary["appProfile"] =
"absent";
let appProfileEntriesRemoved = 0;
let appProfileBindingsRemoved = 0;
let gatewayTokensRevoked = 0;
let gatewaySessionsRevoked = 0;
if (appProfile) {
appProfileEntriesRemoved = await db.transaction(async tx => {
await tx.select({ id: toolProfiles.id }).from(toolProfiles)
.where(and(eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.id, appProfile.id)))
.for("no key update");
return (await tx.delete(toolProfileEntries)
.where(and(eq(toolProfileEntries.companyId, connection.companyId), eq(toolProfileEntries.profileId, appProfile.id)))
.returning({ id: toolProfileEntries.id })).length;
});
appProfileBindingsRemoved = (
await db
.delete(toolProfileBindings)
.where(
and(
eq(toolProfileBindings.companyId, connection.companyId),
eq(toolProfileBindings.profileId, appProfile.id),
),
)
.returning({ id: toolProfileBindings.id })
).length;
const gatewayRows = await db
.select({ id: toolMcpGateways.id })
.from(toolMcpGateways)
.where(
and(
eq(toolMcpGateways.companyId, connection.companyId),
eq(toolMcpGateways.profileId, appProfile.id),
),
);
if (gatewayRows.length === 0) {
await db.delete(toolProfiles).where(eq(toolProfiles.id, appProfile.id));
appProfileOutcome = "deleted";
} else {
// `tool_mcp_gateways.profile_id` is ON DELETE RESTRICT, so a gateway
// pointing here keeps the row alive. Archive it instead — the policy
// engine only consults `active` profiles, and it has no entries left —
// and revoke the tokens those gateways already handed out, which are the
// one credential a caller could still present.
await db
.update(toolProfiles)
.set({ status: "archived", defaultAction: "deny", updatedAt: now })
.where(eq(toolProfiles.id, appProfile.id));
appProfileOutcome = "archived";
const revokedTokens = await db
.update(toolMcpGatewayTokens)
.set({ revokedAt: now, updatedAt: now })
.where(
and(
eq(toolMcpGatewayTokens.companyId, connection.companyId),
inArray(
toolMcpGatewayTokens.gatewayId,
gatewayRows.map((row) => row.id),
),
isNull(toolMcpGatewayTokens.revokedAt),
),
)
.returning({ id: toolMcpGatewayTokens.id });
gatewayTokensRevoked = revokedTokens.length;
if (revokedTokens.length > 0) {
gatewaySessionsRevoked = (
await db
.update(toolGatewaySessions)
.set({ revokedAt: now, updatedAt: now })
.where(
and(
eq(toolGatewaySessions.companyId, connection.companyId),
inArray(
toolGatewaySessions.gatewayTokenId,
revokedTokens.map((row) => row.id),
),
isNull(toolGatewaySessions.revokedAt),
),
)
.returning({ id: toolGatewaySessions.id })
).length;
}
}
}
// A local runtime already holds the injected credential inside a live child
// process, so archiving rows is not enough — the process itself is an access
// path. Stopping is best effort on purpose: if the supervisor cannot be
// reached, revoking the credential anyway (so nothing can start again) beats
// abandoning the teardown, and the warning says which slot was left running.
let runtimeSlotsStopped = 0;
const runtimeSlotRows = await db
.select({ id: toolRuntimeSlots.id, status: toolRuntimeSlots.status })
.from(toolRuntimeSlots)
.where(
and(
eq(toolRuntimeSlots.companyId, connection.companyId),
eq(toolRuntimeSlots.connectionId, connection.id),
),
);
for (const slot of runtimeSlotRows) {
if (slot.status === "stopped") continue;
try {
await runtimeSupervisor.stopSlot({
companyId: connection.companyId,
slotId: slot.id,
reason: "connection_removed",
});
runtimeSlotsStopped += 1;
} catch (error) {
logger.warn(
{
err: error,
companyId: connection.companyId,
connectionId: connection.id,
slotId: slot.id,
},
"tool connection removal could not stop a runtime slot",
);
}
}
// The catalog stays as history, but `removed` is the status that stops a
// standing trust rule from auto-allowing one of these actions again.
const removedCatalogEntries = await db
.update(toolCatalogEntries)
.set({ status: "removed", updatedAt: now })
.where(
and(
eq(toolCatalogEntries.companyId, connection.companyId),
eq(toolCatalogEntries.connectionId, connection.id),
ne(toolCatalogEntries.status, "removed"),
),
)
.returning({ id: toolCatalogEntries.id });
// An authorization already in flight would otherwise come back and mint a
// fresh token for an app the operator just removed.
const discardedOAuthStates = await db
.delete(toolOauthStates)
.where(
and(
eq(toolOauthStates.companyId, connection.companyId),
eq(toolOauthStates.connectionId, connection.id),
),
)
.returning({ state: toolOauthStates.state });
// Token-derived material in the issuance ledger is not an access path —
// nothing validates against it — but there is no reason to keep a hash of a
// credential the operator asked us to revoke. Path, outcome, actor and time
// stay, so the usage history survives.
const clearedIssuanceHashes = await db
.update(connectionTokenIssuances)
.set({ tokenHash: null })
.where(
and(
eq(connectionTokenIssuances.companyId, connection.companyId),
eq(connectionTokenIssuances.connectionId, connection.id),
sql`${connectionTokenIssuances.tokenHash} is not null`,
),
)
.returning({ id: connectionTokenIssuances.id });
const archived = await db.transaction(async (tx) => {
await tx.select({ id: toolApplications.id }).from(toolApplications)
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId)))
.for("update");
const [updatedConnection] = await tx
.update(toolConnections)
.set({ status: "archived", enabled: false, updatedAt: now })
.where(eq(toolConnections.id, connection.id))
.returning();
if (!updatedConnection) throw notFound("Tool connection not found");
const remainingConnections = await tx
.select({ id: toolConnections.id })
.from(toolConnections)
.where(
and(
eq(toolConnections.applicationId, updatedConnection.applicationId),
ne(toolConnections.status, "archived"),
),
)
.limit(1);
let applicationArchived = false;
if (remainingConnections.length === 0) {
const [application] = await tx
.update(toolApplications)
.set({ status: "archived", archivedAt: now, updatedAt: now })
.where(
and(
eq(toolApplications.id, updatedConnection.applicationId),
ne(toolApplications.status, "archived"),
),
)
.returning({ id: toolApplications.id });
applicationArchived = Boolean(application);
}
return { connection: updatedConnection, applicationArchived };
});
emitConnectionUpdated(archived.connection, connection, "archive");
// Hosted work needs its credential to stop. Agent/viewer access is already
// revoked above; retain cleanup authority until provider shutdown confirms.
if (isBrowserUseConnection(connection)) {
await browserUseService(db, options.remoteHttpRequest).stopBeforeCredentialRemoval(connection.companyId, connection.id);
}
// Only now, with every access path closed, revoke the credentials. Each
// `secrets.remove` marks the row deleted before it calls the provider, so a
// provider error leaves an unresolvable secret and a resumable removal
// rather than a half-open app.
const candidateSecretIds = [
...Object.values(asRecord(connection.config.aggregatorDiscovery)).flatMap(value => typeof asRecord(value).secretId === "string" ? [asRecord(value).secretId as string] : []),
...connection.credentialRefs.map((ref) => ref.secretId),
...connection.credentialSecretRefs.map((ref) => ref.secretId),
...grantRows.flatMap((grant) =>
(grant.credentialSecretRefs ?? []).map((ref) => ref.secretId),
),
];
const { owned, retained } = await classifyConnectionSecrets(
connection,
candidateSecretIds,
);
let secretsRevoked = 0;
for (const secretId of owned) {
const removed = await secrets.remove(secretId);
if (removed) secretsRevoked += 1;
}
const credentialRefsCleared =
connection.credentialRefs.length + connection.credentialSecretRefs.length;
const [cleared] = await db
.update(toolConnections)
.set({
credentialRefs: [],
credentialSecretRefs: [],
config: withoutInlineOAuthTokens(connection.config),
transportConfig: withoutInlineOAuthTokens(connection.transportConfig),
updatedAt: now,
})
.where(eq(toolConnections.id, connection.id))
.returning();
if (
grantRows.some((grant) => (grant.credentialSecretRefs ?? []).length > 0)
) {
await db
.update(connectionGrants)
.set({ credentialSecretRefs: [], updatedAt: now })
.where(
and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
),
);
}
return {
connection: toConnection(cleared ?? archived.connection),
removal: {
secretsRevoked,
secretsRetainedShared: retained.length,
credentialRefsCleared,
secretBindingsRemoved: removedSecretBindings.length,
grantsRevoked: grantsToRevoke.length,
installsRemoved: removedInstalls.length,
appProfile: appProfileOutcome,
appProfileEntriesRemoved,
appProfileBindingsRemoved,
catalogEntriesMarkedRemoved: removedCatalogEntries.length,
oauthStatesDiscarded: discardedOAuthStates.length,
tokenIssuanceHashesCleared: clearedIssuanceHashes.length,
runtimeSlotsStopped,
gatewayTokensRevoked,
gatewaySessionsRevoked,
applicationArchived: archived.applicationArchived,
externalCredentialCleanup,
},
};
}
return removeConnection;
}