refactor(tools): isolate profile ownership (#17155)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tool profiles select the actions each agent can use.
> - Profile reads, review, mutation and assignment resolution shared a
large OAuth and connection service.
> - Those paths have their own company checks, lock order and database
projections.
> - This pull request gives profiles explicit owners while preserving
the existing transactions and public API.
> - The benefit is a smaller coordinator and a reviewable access
boundary.

## Linked Issues or Issue Description

Depends on #17147. Stack: #17144 → #17146 → #17147 → this PR. These PRs
remain open.

**What existing behavior does this improve?**

Maintenance of tool profile management and effective agent assignments.

**Current behavior**

The service entry point contains 19,293 lines. Profile behavior is
spread across its closure and returned object.

**Proposed behavior**

Profile management accepts only a database and clock. Assignment
resolution accepts three named capabilities. Projection and shared lock
functions have explicit homes. Public methods and responses remain
unchanged.

**Reason and benefit**

Reviewers can follow profile transactions, lightweight reads and
precedence without navigating OAuth setup. The entry point falls to
17,752 lines, a reduction of 1,541 lines in this PR.

## What Changed

- Extract profile management, projections and effective assignment
resolution into three modules. Their sizes are 886, 268 and 216 lines.
- Move shared actor/transaction types, identifiers and assignment locks
into named modules. Preserve the public lock export.
- Keep all database reads, transaction boundaries, lock order and method
signatures. Add no cache or background work.
- Record the next ownership cuts toward an entry point below 5,000
lines.

## Verification

- Before and after: 466 cases in `tool-access-service.test.ts` and 33
cases across profile entry validation, effective assignments and catalog
publication. All 499 cases pass with one database worker.
- Server TypeScript check and `node scripts/check-module-boundaries.mjs`
pass. `git diff --check` passes.
- Normalized syntax comparison finds all 329 pre-existing
function/method bodies unchanged. Wiring passes the unchanged public
service tests.
- The previous stack passed full typecheck and build. Its full local
test run was interrupted and remains unverified; its complete CI matrix
passed. This PR will record its own current-head CI result below.

- Current-head CI is green. Current review: Greptile is 5/5 at
`0f5c85905e`, with no inline review threads.
- CI failure preserved: [general server shard
1](https://github.com/paperclipai/paperclip/actions/runs/39704932041/job/119582710174)
failed the unchanged workspace-runtime test that replaces an unhealthy
local process. The replacement refused connections for its original
10-second readiness budget. The fixture and runtime service are
unchanged and do not import tool-access. One isolated run passed without
changes or timeout increases. The failure log is preserved; the cause is
not reproduced. Only that failed job was retried; it passed at the same
head without code changes. The original failure remains recorded above.

## Risks

- Moving profile mutations could change transaction clients or lock
order. Both are unchanged and the existing database tests cover
publication and assignment behavior.
- Effective assignment resolution still uses the same
installed-connection loader and full catalog projection. The lightweight
path still omits large schema and metadata fields.
- No schema, public API or access policy change is intended. Existing
tests provide characterization coverage; no new test mirrors the
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:21:42 -05:00
1 parent 4413bb262f
commit 233753e577
8 files changed
+1521 -1558

No files matched your search

@@ -108,3 +108,50 @@ Token brokering follows after its authorization, issuance audit, and locking
contract is mapped. The large setup and callback functions should shrink through
those ownership cuts rather than by distributing their local variables into a
shared service context.
## Continued stack: coordinator below 5,000 lines
The user authorized continuing the stack after PRs #17144, #17146, and #17147.
All three remain open. Start each dependent PR from its predecessor; do not merge.
The new baseline is `3dc46c132408556ecb31038a9b9621ac20aacefc`, at 19,293 lines.
The next cuts follow ownership, with a measured target of fewer than 5,000 lines
in the entry point. Reassess the estimates and ordering after each cut.
| Boundary | Approximate reduction | Owner and contract | State and verification |
| --- | ---: | --- | --- |
| Profile management and effective assignments | 1,500 | Profile service owns scoped queries, mutations, review and summaries; assignment resolver consumes installed connections | Preserve caller transactions, lock ordering, precedence, exclusions, and lightweight reads |
| Connection token broker | 1,700 | Authorized run and issuance request to credential lease; provider exchange separated from admission | Keep run/membership checks, rate limits, scope restriction, issuance audit and cleanup in one flow |
| Runtime administration and diagnostics | 1,500 | Stdio templates, supervisor controls, health and activity projections | Same supervisor instance and live clock; public routes and real runtime controls |
| Connection credentials and refresh | 2,800 | Current grant and actor to usable credentials; separate registration and refresh owners | Preserve process registration flights, per-service refresh flights, database leases and current authority checks |
| OAuth authorization lifecycle | 2,700 | State admission, authorization request and provider-specific callback completion | Keep state consumption and callback finalization ordering; local HTTP OAuth/MCP integration and denial/revocation cases |
| Catalog and provider discovery | 1,900 | Connection to normalized descriptors and reviewed catalog; aggregator inventory has its own owner | Preserve discovery caches, credential access, failure classification and catalog publication transactions |
| Connection grants, installs and removal | 2,300 | Company-scoped connection lifecycle and audience operations | Preserve revocation/assignment lock ordering, current membership, teardown and secret ownership |
| Example installation, if still needed | 600 | Reviewed examples to installed application/profile plus smoke results | Preserve idempotency, approved runtime and policy decisions |
Shared support is limited to explicit actor/transaction types, stable identifiers,
and assignment-lock functions. It must not become a service-context bag. The
entry point should retain cross-domain app setup and access finalization, while
subsystems own their implementation. Existing public imports remain compatible.
Run the full public service baseline, then each boundary's meaningful tests
before and after its move. Review normalized bodies and every wiring change.
Run full typecheck/build at the batch handoff and require current-head CI gates.
The prior batch's interrupted local full suite remains unverified; its passing
isolated retry does not replace the missing full-run result.
### Profile ownership extraction
Profile management now takes only the database and clock. Pure projections and
lightweight catalog columns live beside it. Effective assignment resolution has
three explicit capabilities: validate the agent, load installed connections, and
project full catalog entries. Shared assignment locks keep their bodies and call
order. Transactions still enclose the same reads, locks and writes.
The entry point is 17,752 lines / 698,511 bytes. The profile modules contain 1,370
lines, with the largest at 886. All 329 pre-existing function and method bodies
match normalized syntax across the move. The baseline and post-move runs pass
466 public service cases plus 33 profile validation, assignment and publication
cases. Server typechecking and the module-boundary gate pass. Existing tests
exercise both full catalog DTOs and lightweight assignments; no new behavior or
fixture-only test was added.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,46 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { agents, toolApplications, toolCatalogEntries, toolConnections, toolPolicies, toolProfiles } from "@paperclipai/db";
import type { CreateToolProfileEntryForProfile } from "@paperclipai/shared";
import type { ToolAccessMutationDb } from "./types.js";
export async function lockManagedAppPolicies(client: ToolAccessMutationDb, companyId: string, connectionId: string) {
await client.select({ id: toolPolicies.id }).from(toolPolicies).where(and(
eq(toolPolicies.companyId, companyId), eq(toolPolicies.policyType, "require_approval"),
sql`${toolPolicies.config}->>'source' = 'app_gallery_finish'`,
sql`${toolPolicies.config}->>'connectionId' = ${connectionId}`,
)).orderBy(toolPolicies.id).for("no key update");
}
export async function lockToolAssignmentAgents(client: ToolAccessMutationDb, companyId: string, targets: ReadonlyArray<{ targetType: string; targetId: string }>) {
const allAgents = targets.some(target => target.targetType === "company" && target.targetId === companyId);
const ids = targets.filter(target => target.targetType === "agent").map(target => target.targetId);
if (!allAgents && !ids.length) return;
await client.select({ id: agents.id }).from(agents)
.where(and(eq(agents.companyId, companyId), sql`${agents.status} NOT IN ('pending_approval', 'terminated')`,
allAgents ? undefined : inArray(agents.id, ids)))
.orderBy(agents.id).for("no key update");
}
export async function lockToolConnectionRuntimeInputs(client: ToolAccessMutationDb, companyId: string, connectionId: string) {
await client.select({ id: toolApplications.id }).from(toolApplications)
.innerJoin(toolConnections, and(eq(toolConnections.applicationId, toolApplications.id), eq(toolConnections.companyId, toolApplications.companyId)))
.where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId)))
.for("update", { of: toolApplications });
await client.select({ id: toolConnections.id }).from(toolConnections)
.where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update");
await client.select({ id: toolProfiles.id }).from(toolProfiles)
.where(and(eq(toolProfiles.companyId, companyId), eq(toolProfiles.profileKey, `app:${connectionId}`)))
.for("no key update");
await lockManagedAppPolicies(client, companyId, connectionId);
}
export async function lockProfileEntryReferences(client: ToolAccessMutationDb, companyId: string, entries: Pick<CreateToolProfileEntryForProfile, "applicationId" | "connectionId" | "catalogEntryId">[]) {
const applicationIds = [...new Set(entries.flatMap(entry => entry.applicationId ? [entry.applicationId] : []))];
const connectionIds = [...new Set(entries.flatMap(entry => entry.connectionId ? [entry.connectionId] : []))];
const catalogIds = [...new Set(entries.flatMap(entry => entry.catalogEntryId ? [entry.catalogEntryId] : []))];
if (applicationIds.length) await client.select({ id: toolApplications.id }).from(toolApplications)
.where(and(eq(toolApplications.companyId, companyId), inArray(toolApplications.id, applicationIds))).orderBy(toolApplications.id).for("key share");
if (connectionIds.length) await client.select({ id: toolConnections.id }).from(toolConnections)
.where(and(eq(toolConnections.companyId, companyId), inArray(toolConnections.id, connectionIds))).orderBy(toolConnections.id).for("key share");
if (catalogIds.length) await client.select({ id: toolCatalogEntries.id }).from(toolCatalogEntries)
.where(and(eq(toolCatalogEntries.companyId, companyId), inArray(toolCatalogEntries.id, catalogIds))).orderBy(toolCatalogEntries.id).for("key share");
}
@@ -0,0 +1,16 @@
export function normalizeKey(input: string) {
return (
input
.trim()
.toLowerCase()
.replace(/[^a-z0-9._:-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 160) || "tool"
);
}
export function connectionUid(namespace: string, name: string, connectionId: string) {
return `${normalizeKey(namespace)}/${normalizeKey(name)}-${connectionId.slice(0, 8)}`;
}
@@ -0,0 +1,270 @@
import { toolApplications, toolCatalogEntries, toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
import type { ToolProfile, ToolProfileBinding, ToolProfileEntry, ToolProfileNewToolReviewItem, ToolProfileSummary, ToolProfileWithDetails } from "@paperclipai/shared";
export function toProfile(row: typeof toolProfiles.$inferSelect): ToolProfile {
return {
id: row.id,
companyId: row.companyId,
profileKey: row.profileKey,
name: row.name,
description: row.description,
status: row.status,
defaultAction: row.defaultAction,
newToolsReviewedAt: row.newToolsReviewedAt,
metadata: row.metadata ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function toProfileEntry(
row: typeof toolProfileEntries.$inferSelect,
): ToolProfileEntry {
return {
id: row.id,
companyId: row.companyId,
profileId: row.profileId,
selectorType: row.selectorType,
effect: row.effect,
applicationId: row.applicationId,
connectionId: row.connectionId,
catalogEntryId: row.catalogEntryId,
toolName: row.toolName,
riskLevel: row.riskLevel,
conditions: row.conditions ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export function toProfileBinding(
row: typeof toolProfileBindings.$inferSelect,
): ToolProfileBinding {
return {
id: row.id,
companyId: row.companyId,
profileId: row.profileId,
targetType: row.targetType,
targetId: row.targetId,
priority: row.priority,
metadata: row.metadata ?? null,
createdByAgentId: row.createdByAgentId,
createdByUserId: row.createdByUserId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// Profile summaries and new-tool counts exclude schemas, annotations, and
// provider metadata from these repeated reads.
export const profileCatalogColumns = {
id: toolCatalogEntries.id,
applicationId: toolCatalogEntries.applicationId,
connectionId: toolCatalogEntries.connectionId,
toolName: toolCatalogEntries.toolName,
riskLevel: toolCatalogEntries.riskLevel,
status: toolCatalogEntries.status,
firstSeenAt: toolCatalogEntries.firstSeenAt,
};
export const profileReviewCatalogColumns = {
...profileCatalogColumns,
title: toolCatalogEntries.title,
description: toolCatalogEntries.description,
};
export type ProfileCatalogEntry = Pick<typeof toolCatalogEntries.$inferSelect, keyof typeof profileCatalogColumns>
& Partial<Pick<typeof toolCatalogEntries.$inferSelect, "title" | "description">>;
export type ProfileApplication = Pick<typeof toolApplications.$inferSelect, "id" | "name">;
export type ProfileConnection = Pick<typeof toolConnections.$inferSelect, "id" | "name">;
export function profileEntryMatchesCatalog(
entry: typeof toolProfileEntries.$inferSelect,
catalogEntry: ProfileCatalogEntry,
): boolean {
if (entry.selectorType === "application")
return entry.applicationId === catalogEntry.applicationId;
if (entry.selectorType === "connection")
return entry.connectionId === catalogEntry.connectionId;
if (entry.selectorType === "catalog_entry")
return entry.catalogEntryId === catalogEntry.id;
if (entry.selectorType === "tool_name")
return entry.toolName === catalogEntry.toolName;
if (entry.selectorType === "risk_level")
return entry.riskLevel === catalogEntry.riskLevel;
return false;
}
export function summarizeProfile(input: {
profile: typeof toolProfiles.$inferSelect;
entries: Array<typeof toolProfileEntries.$inferSelect>;
bindings: Array<typeof toolProfileBindings.$inferSelect>;
catalog: ProfileCatalogEntry[];
agentIds: string[];
}): ToolProfileSummary {
const includes = input.entries.filter((entry) => entry.effect === "include");
const excludes = input.entries.filter((entry) => entry.effect === "exclude");
const allowedCatalogIds = new Set<string>();
const allowedApplicationIds = new Set<string>();
const excludedCatalogIds = new Set<string>();
for (const catalogEntry of input.catalog) {
const excluded = excludes.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
);
if (excluded) excludedCatalogIds.add(catalogEntry.id);
if (excluded) continue;
const included = includes.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
);
if (input.profile.defaultAction === "allow" || included) {
allowedCatalogIds.add(catalogEntry.id);
if (catalogEntry.applicationId)
allowedApplicationIds.add(catalogEntry.applicationId);
}
}
const isCompanyDefault = input.bindings.some(
(binding) =>
binding.targetType === "company" &&
binding.targetId === input.profile.companyId,
);
const appliesToAgents = new Set<string>();
if (isCompanyDefault) {
for (const agentId of input.agentIds) appliesToAgents.add(agentId);
} else {
const companyAgentIds = new Set(input.agentIds);
for (const binding of input.bindings) {
if (
binding.targetType === "agent" &&
companyAgentIds.has(binding.targetId)
) {
appliesToAgents.add(binding.targetId);
}
}
}
return {
accessMode:
input.profile.defaultAction === "allow" ? "all_except" : "selected",
allowedToolCount: allowedCatalogIds.size,
allowedApplicationCount: allowedApplicationIds.size,
excludedToolCount: excludedCatalogIds.size,
totalToolCount: input.catalog.length,
assignmentCount: input.bindings.length,
appliesToAgentCount: appliesToAgents.size,
isCompanyDefault,
};
}
export function profileCoversCatalogScope(input: {
entry: typeof toolProfileEntries.$inferSelect;
catalogEntry: ProfileCatalogEntry;
catalogById: Map<string, ProfileCatalogEntry>;
}): boolean {
if (input.entry.effect !== "include") return false;
if (input.entry.selectorType === "application")
return input.entry.applicationId === input.catalogEntry.applicationId;
if (input.entry.selectorType === "connection")
return input.entry.connectionId === input.catalogEntry.connectionId;
if (
input.entry.selectorType !== "catalog_entry" ||
!input.entry.catalogEntryId
)
return false;
const scopedEntry = input.catalogById.get(input.entry.catalogEntryId);
if (!scopedEntry) return false;
if (scopedEntry.connectionId === input.catalogEntry.connectionId) return true;
return Boolean(
scopedEntry.applicationId &&
scopedEntry.applicationId === input.catalogEntry.applicationId,
);
}
export function pendingNewToolsForProfile(input: {
profile: typeof toolProfiles.$inferSelect;
entries: Array<typeof toolProfileEntries.$inferSelect>;
catalog: ProfileCatalogEntry[];
applicationsById?: Map<string, ProfileApplication>;
connectionsById?: Map<string, ProfileConnection>;
}): ToolProfileNewToolReviewItem[] {
if (
input.profile.status !== "active" ||
input.profile.defaultAction !== "deny"
)
return [];
const watermark = input.profile.newToolsReviewedAt ?? input.profile.createdAt;
const catalogById = new Map(input.catalog.map((entry) => [entry.id, entry]));
const scopedIncludes = input.entries.filter(
(entry) =>
entry.effect === "include" &&
(entry.selectorType === "application" ||
entry.selectorType === "connection" ||
entry.selectorType === "catalog_entry"),
);
if (scopedIncludes.length === 0) return [];
return input.catalog
.filter(
(catalogEntry) =>
catalogEntry.status === "active" ||
catalogEntry.status === "quarantined",
)
.filter((catalogEntry) => catalogEntry.firstSeenAt > watermark)
.filter((catalogEntry) =>
scopedIncludes.some((entry) =>
profileCoversCatalogScope({ entry, catalogEntry, catalogById }),
),
)
.filter(
(catalogEntry) =>
!input.entries.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
),
)
.map((catalogEntry) => ({
catalogEntryId: catalogEntry.id,
applicationId: catalogEntry.applicationId,
applicationName: catalogEntry.applicationId
? (input.applicationsById?.get(catalogEntry.applicationId)?.name ??
null)
: null,
connectionId: catalogEntry.connectionId,
connectionName:
input.connectionsById?.get(catalogEntry.connectionId)?.name ?? null,
toolName: catalogEntry.toolName,
title: catalogEntry.title ?? null,
description: catalogEntry.description ?? null,
capability: catalogEntry.riskLevel,
riskLevel: catalogEntry.riskLevel,
addedAt: catalogEntry.firstSeenAt,
firstSeenAt: catalogEntry.firstSeenAt,
}));
}
export function buildProfileDetails(input: {
profile: typeof toolProfiles.$inferSelect;
entries: Array<typeof toolProfileEntries.$inferSelect>;
bindings: Array<typeof toolProfileBindings.$inferSelect>;
catalog: ProfileCatalogEntry[];
agentIds: string[];
applicationsById?: Map<string, ProfileApplication>;
connectionsById?: Map<string, ProfileConnection>;
}): ToolProfileWithDetails {
const pendingNewTools = pendingNewToolsForProfile({
profile: input.profile,
entries: input.entries,
catalog: input.catalog,
applicationsById: input.applicationsById,
connectionsById: input.connectionsById,
});
return {
...toProfile(input.profile),
newToolsPendingCount: pendingNewTools.length,
entries: input.entries.map(toProfileEntry),
bindings: input.bindings.map(toProfileBinding),
summary: summarizeProfile(input),
};
}
@@ -0,0 +1,216 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, toolCatalogEntries, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
import type { ToolCatalogEntry, ToolConnection, ToolProfileEffectiveSummary, ToolProfileWithDetails } from "@paperclipai/shared";
import { effectiveToolProfileBindings, profileIdsInBindingOrder } from "../../tool-profile-binding-precedence.js";
import { buildProfileDetails, profileCatalogColumns, profileEntryMatchesCatalog, toProfileBinding, toProfileEntry, type ProfileCatalogEntry } from "./projection.js";
type ProfileAssignmentDependencies = {
assertOptionalAgent(companyId: string, agentId: string | null | undefined, label: string): Promise<void>;
resolveInstalledConnectionsForAgent(companyId: string, agentId: string): Promise<ToolConnection[]>;
toCatalogEntry(row: typeof toolCatalogEntries.$inferSelect): ToolCatalogEntry;
};
export function createToolProfileResolver(db: Db, { assertOptionalAgent, resolveInstalledConnectionsForAgent, toCatalogEntry }: ProfileAssignmentDependencies) {
async function resolveEffectiveProfiles<TEntry extends ProfileCatalogEntry, TAllowedTool>(
companyId: string,
agentId: string,
loadCatalog: () => PromiseLike<TEntry[]>,
toAllowedTool: (entry: TEntry) => TAllowedTool,
): Promise<Omit<ToolProfileEffectiveSummary, "allowedTools"> & { allowedTools: TAllowedTool[] }> {
await assertOptionalAgent(
companyId,
agentId,
"Tool profile effective agent",
);
const allBindings = await db
.select()
.from(toolProfileBindings)
.where(eq(toolProfileBindings.companyId, companyId))
.orderBy(
asc(toolProfileBindings.priority),
asc(toolProfileBindings.createdAt),
);
const matchingBindings = allBindings.filter(
(binding) =>
(binding.targetType === "company" &&
binding.targetId === companyId) ||
(binding.targetType === "agent" && binding.targetId === agentId),
);
if (matchingBindings.length === 0) {
return {
agentId,
profiles: [],
entries: [],
bindings: [],
allowedTools: [],
allowedToolNames: [],
installedConnections: await resolveInstalledConnectionsForAgent(
companyId,
agentId,
),
};
}
const candidateProfileIds = profileIdsInBindingOrder(matchingBindings);
const candidateProfiles = await db
.select()
.from(toolProfiles)
.where(
and(
eq(toolProfiles.companyId, companyId),
inArray(toolProfiles.id, candidateProfileIds),
),
);
const bindings = effectiveToolProfileBindings(
matchingBindings,
candidateProfiles,
);
const profileIds = profileIdsInBindingOrder(bindings);
const profilesById = new Map(
candidateProfiles.map((profile) => [profile.id, profile]),
);
const activeProfiles = profileIds
.map((profileId) => profilesById.get(profileId) ?? null)
.filter((profile): profile is typeof toolProfiles.$inferSelect =>
Boolean(profile && profile.status === "active"),
);
if (activeProfiles.length === 0) {
return {
agentId,
profiles: [],
entries: [],
bindings: bindings.map(toProfileBinding),
allowedTools: [],
allowedToolNames: [],
installedConnections: await resolveInstalledConnectionsForAgent(
companyId,
agentId,
),
};
}
const activeProfileIds = activeProfiles.map((profile) => profile.id);
const [entries, catalog, companyAgents] = await Promise.all([
db
.select()
.from(toolProfileEntries)
.where(
and(
eq(toolProfileEntries.companyId, companyId),
inArray(toolProfileEntries.profileId, activeProfileIds),
),
)
.orderBy(asc(toolProfileEntries.createdAt)),
loadCatalog(),
db
.select({ id: agents.id })
.from(agents)
.where(eq(agents.companyId, companyId)),
]);
const entriesByProfile = new Map<
string,
Array<typeof toolProfileEntries.$inferSelect>
>();
for (const entry of entries) {
const list = entriesByProfile.get(entry.profileId) ?? [];
list.push(entry);
entriesByProfile.set(entry.profileId, list);
}
const allowedCatalogIds = new Set<string>();
const allowedToolNames = new Set<string>();
for (const profile of activeProfiles) {
const profileEntries = entriesByProfile.get(profile.id) ?? [];
const includes = profileEntries.filter(
(entry) => entry.effect === "include",
);
const excludes = profileEntries.filter(
(entry) => entry.effect === "exclude",
);
for (const catalogEntry of catalog) {
if (
excludes.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
)
)
continue;
if (
profile.defaultAction === "allow" ||
includes.some((entry) =>
profileEntryMatchesCatalog(entry, catalogEntry),
)
) {
allowedCatalogIds.add(catalogEntry.id);
allowedToolNames.add(catalogEntry.toolName);
}
}
for (const entry of includes.filter(
(item) => item.selectorType === "tool_name" && item.toolName,
)) {
const matchingExclude = excludes.some(
(item) =>
item.selectorType === "tool_name" &&
item.toolName === entry.toolName,
);
if (!matchingExclude) allowedToolNames.add(entry.toolName!);
}
}
const agentIds = companyAgents.map((agent) => agent.id);
const details: ToolProfileWithDetails[] = activeProfiles.map((profile) =>
buildProfileDetails({
profile,
entries: entriesByProfile.get(profile.id) ?? [],
bindings: bindings.filter(
(binding) => binding.profileId === profile.id,
),
catalog,
agentIds,
}),
);
const allowedTools = catalog
.filter((entry) => allowedCatalogIds.has(entry.id))
.map(toAllowedTool);
return {
agentId,
profiles: details,
entries: entries.map(toProfileEntry),
bindings: bindings.map(toProfileBinding),
allowedTools,
allowedToolNames: [...allowedToolNames].sort((a, b) =>
a.localeCompare(b),
),
installedConnections: await resolveInstalledConnectionsForAgent(
companyId,
agentId,
),
};
}
return {
getEffectiveProfilesForAgent: (companyId: string, agentId: string) =>
resolveEffectiveProfiles(
companyId,
agentId,
() => db
.select()
.from(toolCatalogEntries)
.where(and(
eq(toolCatalogEntries.companyId, companyId),
eq(toolCatalogEntries.status, "active"),
))
.orderBy(asc(toolCatalogEntries.toolName)),
toCatalogEntry,
),
getEffectiveToolAssignmentsForAgent: (companyId: string, agentId: string) =>
resolveEffectiveProfiles(
companyId,
agentId,
() => db
.select(profileCatalogColumns)
.from(toolCatalogEntries)
.where(and(
eq(toolCatalogEntries.companyId, companyId),
eq(toolCatalogEntries.status, "active"),
))
.orderBy(asc(toolCatalogEntries.toolName)),
({ id, applicationId, connectionId, toolName }) => ({ id, applicationId, connectionId, toolName }),
)
};
}
@@ -0,0 +1,887 @@
import { assertProfileEntryInputs } from "../../tool-profile-entry-validation.js";
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, issues, projects, routines, toolApplications, toolCatalogEntries, toolConnections, toolMcpGateways, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
import type { CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, CreateToolProfileWithEntries, DeleteToolProfile, DuplicateToolProfile, ToolProfile, ToolProfileBinding, ToolProfileEntry, ToolProfileNewToolReviewItem, ToolProfileNewToolsReview, ToolProfileNewToolsReviewResult, ToolProfileSummary, ToolProfileWithDetails, ReviewToolProfileNewTools, UpdateToolProfileEntry, UpdateToolProfileWithEntries, UnbindToolProfileBinding } from "@paperclipai/shared";
import { badRequest, conflict, notFound, unprocessable } from "../../../errors.js";
import type { ActorInfo, ToolAccessMutationDb } from "../types.js";
import { lockProfileEntryReferences, lockToolAssignmentAgents } from "../assignment-locks.js";
import { normalizeKey } from "../identifiers.js";
import { toProfile, toProfileEntry, toProfileBinding, profileCatalogColumns, profileReviewCatalogColumns, pendingNewToolsForProfile, buildProfileDetails } from "./projection.js";
export function createToolProfileService(db: Db, now: () => Date) {
async function getProfileRow(profileId: string, companyId?: string, dbClient: ToolAccessMutationDb = db) {
const where = companyId
? and(
eq(toolProfiles.id, profileId),
eq(toolProfiles.companyId, companyId),
)
: eq(toolProfiles.id, profileId);
const [row] = await dbClient.select().from(toolProfiles).where(where);
if (!row) throw notFound("Tool profile not found");
return row;
}
async function profileDetails(
profileId: string,
companyId?: string,
dbClient: ToolAccessMutationDb = db,
): Promise<ToolProfileWithDetails> {
const profile = await getProfileRow(profileId, companyId, dbClient);
const [
entries,
bindings,
catalog,
companyAgents,
applications,
connections,
] = await Promise.all([
dbClient
.select()
.from(toolProfileEntries)
.where(
and(
eq(toolProfileEntries.companyId, profile.companyId),
eq(toolProfileEntries.profileId, profile.id),
),
)
.orderBy(asc(toolProfileEntries.createdAt)),
dbClient
.select()
.from(toolProfileBindings)
.where(
and(
eq(toolProfileBindings.companyId, profile.companyId),
eq(toolProfileBindings.profileId, profile.id),
),
)
.orderBy(
asc(toolProfileBindings.priority),
asc(toolProfileBindings.createdAt),
),
dbClient
.select(profileCatalogColumns)
.from(toolCatalogEntries)
.where(
and(
eq(toolCatalogEntries.companyId, profile.companyId),
eq(toolCatalogEntries.status, "active"),
),
),
dbClient
.select({ id: agents.id })
.from(agents)
.where(eq(agents.companyId, profile.companyId)),
dbClient
.select({ id: toolApplications.id, name: toolApplications.name })
.from(toolApplications)
.where(eq(toolApplications.companyId, profile.companyId)),
dbClient
.select({ id: toolConnections.id, name: toolConnections.name })
.from(toolConnections)
.where(eq(toolConnections.companyId, profile.companyId)),
]);
return buildProfileDetails({
profile,
entries,
bindings,
catalog,
agentIds: companyAgents.map((agent) => agent.id),
applicationsById: new Map(
applications.map((application) => [application.id, application]),
),
connectionsById: new Map(
connections.map((connection) => [connection.id, connection]),
),
});
}
async function listProfileNewTools(
profileId: string,
companyId?: string,
): Promise<ToolProfileNewToolsReview> {
const profile = await getProfileRow(profileId, companyId);
const [entries, catalog, applications, connections] = await Promise.all([
db
.select()
.from(toolProfileEntries)
.where(
and(
eq(toolProfileEntries.companyId, profile.companyId),
eq(toolProfileEntries.profileId, profile.id),
),
)
.orderBy(asc(toolProfileEntries.createdAt)),
db
.select(profileReviewCatalogColumns)
.from(toolCatalogEntries)
.where(
and(
eq(toolCatalogEntries.companyId, profile.companyId),
eq(toolCatalogEntries.status, "active"),
),
)
.orderBy(asc(toolCatalogEntries.toolName)),
db
.select({ id: toolApplications.id, name: toolApplications.name })
.from(toolApplications)
.where(eq(toolApplications.companyId, profile.companyId)),
db
.select({ id: toolConnections.id, name: toolConnections.name })
.from(toolConnections)
.where(eq(toolConnections.companyId, profile.companyId)),
]);
const tools = pendingNewToolsForProfile({
profile,
entries,
catalog,
applicationsById: new Map(
applications.map((application) => [application.id, application]),
),
connectionsById: new Map(
connections.map((connection) => [connection.id, connection]),
),
});
return {
profileId: profile.id,
reviewedAt: profile.newToolsReviewedAt,
pendingCount: tools.length,
tools,
};
}
async function reviewProfileNewTools(
profileId: string,
input: ReviewToolProfileNewTools,
actor?: ActorInfo,
): Promise<ToolProfileNewToolsReviewResult> {
const profile = await getProfileRow(profileId);
const review = await listProfileNewTools(profile.id, profile.companyId);
if (review.tools.length === 0)
throw badRequest("No new tools are pending review for this profile");
const decisionIds = input.decisions.map(
(decision) => decision.catalogEntryId,
);
if (new Set(decisionIds).size !== decisionIds.length) {
throw badRequest(
"New-tools review decisions must not contain duplicate catalogEntryId values",
);
}
const pendingIds = new Set(review.tools.map((tool) => tool.catalogEntryId));
if (
decisionIds.length !== pendingIds.size ||
decisionIds.some((id) => !pendingIds.has(id))
) {
throw badRequest(
"New-tools review decisions must cover every currently pending tool exactly once",
);
}
const toolById = new Map(
review.tools.map((tool) => [tool.catalogEntryId, tool]),
);
const allowTools = input.decisions
.filter((decision) => decision.decision === "allow")
.map((decision) => toolById.get(decision.catalogEntryId))
.filter(Boolean) as ToolProfileNewToolReviewItem[];
const nowAt = now();
let createdEntries: ToolProfileEntry[] = [];
if (allowTools.length > 0) {
const rows = await db
.insert(toolProfileEntries)
.values(
allowTools.map((tool) => ({
companyId: profile.companyId,
profileId: profile.id,
selectorType: "catalog_entry" as const,
effect: "include" as const,
applicationId: tool.applicationId,
connectionId: tool.connectionId,
catalogEntryId: tool.catalogEntryId,
})),
)
.returning();
createdEntries = rows.map(toProfileEntry);
}
await db
.update(toolCatalogEntries)
.set({
reviewedAt: nowAt,
reviewedByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
reviewedByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
updatedAt: nowAt,
})
.where(
and(
eq(toolCatalogEntries.companyId, profile.companyId),
inArray(toolCatalogEntries.id, decisionIds),
),
);
await db
.update(toolProfiles)
.set({ newToolsReviewedAt: nowAt, updatedAt: nowAt })
.where(eq(toolProfiles.id, profile.id));
return {
profile: await profileDetails(profile.id, profile.companyId),
reviewedAt: nowAt,
allowedCount: allowTools.length,
keptBlockedCount: input.decisions.length - allowTools.length,
entriesCreated: createdEntries,
reviewedCatalogEntryIds: decisionIds,
};
}
async function createProfileEntries(
companyId: string,
profileId: string,
entries: CreateToolProfileEntryForProfile[],
dbClient: ToolAccessMutationDb = db,
) {
await assertProfileEntryInputs(dbClient, companyId, entries);
if (entries.length === 0) return;
await dbClient.insert(toolProfileEntries).values(
entries.map((entry) => ({
companyId,
profileId,
selectorType: entry.selectorType,
effect: entry.effect ?? "include",
applicationId: entry.applicationId ?? null,
connectionId: entry.connectionId ?? null,
catalogEntryId: entry.catalogEntryId ?? null,
toolName: entry.toolName ?? null,
riskLevel: entry.riskLevel ?? null,
conditions: entry.conditions ?? null,
})),
);
}
async function replaceProfileEntries(
companyId: string,
profileId: string,
entries: CreateToolProfileEntryForProfile[],
dbClient: ToolAccessMutationDb = db,
) {
await assertProfileEntryInputs(dbClient, companyId, entries);
await dbClient
.delete(toolProfileEntries)
.where(
and(
eq(toolProfileEntries.companyId, companyId),
eq(toolProfileEntries.profileId, profileId),
),
);
await createProfileEntries(companyId, profileId, entries, dbClient);
}
async function assertTargetExists(
companyId: string,
targetType: CreateToolProfileBindingForProfile["targetType"],
targetId: string,
dbClient: ToolAccessMutationDb = db,
) {
if (targetType === "company") {
if (targetId !== companyId)
throw unprocessable(
"Company profile bindings must target the same company id",
);
return;
}
if (targetType === "agent") {
const [row] = await dbClient
.select({ id: agents.id })
.from(agents)
.where(and(eq(agents.id, targetId), eq(agents.companyId, companyId)));
if (!row)
throw unprocessable(
"Tool profile agent binding target must belong to the same company",
);
return;
}
if (targetType === "project") {
const [row] = await dbClient
.select({ id: projects.id })
.from(projects)
.where(
and(eq(projects.id, targetId), eq(projects.companyId, companyId)),
);
if (!row)
throw unprocessable(
"Tool profile project binding target must belong to the same company",
);
return;
}
if (targetType === "routine") {
const [row] = await dbClient
.select({ id: routines.id })
.from(routines)
.where(
and(eq(routines.id, targetId), eq(routines.companyId, companyId)),
);
if (!row)
throw unprocessable(
"Tool profile routine binding target must belong to the same company",
);
return;
}
if (targetType === "issue") {
const [row] = await dbClient
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.id, targetId), eq(issues.companyId, companyId)));
if (!row)
throw unprocessable(
"Tool profile issue binding target must belong to the same company",
);
return;
}
if (targetType === "gateway") {
const [row] = await dbClient
.select({ id: toolMcpGateways.id })
.from(toolMcpGateways)
.where(
and(
eq(toolMcpGateways.id, targetId),
eq(toolMcpGateways.companyId, companyId),
),
);
if (!row)
throw unprocessable(
"Tool profile gateway binding target must belong to the same company",
);
}
}
return {
listProfiles: async (
companyId: string,
): Promise<ToolProfileWithDetails[]> => {
const profiles = await db
.select()
.from(toolProfiles)
.where(eq(toolProfiles.companyId, companyId))
.orderBy(desc(toolProfiles.updatedAt));
if (profiles.length === 0) return [];
const profileIds = profiles.map((profile) => profile.id);
const [
entries,
bindings,
catalog,
companyAgents,
applications,
connections,
] = await Promise.all([
db
.select()
.from(toolProfileEntries)
.where(
and(
eq(toolProfileEntries.companyId, companyId),
inArray(toolProfileEntries.profileId, profileIds),
),
)
.orderBy(asc(toolProfileEntries.createdAt)),
db
.select()
.from(toolProfileBindings)
.where(
and(
eq(toolProfileBindings.companyId, companyId),
inArray(toolProfileBindings.profileId, profileIds),
),
)
.orderBy(
asc(toolProfileBindings.priority),
asc(toolProfileBindings.createdAt),
),
db
.select(profileCatalogColumns)
.from(toolCatalogEntries)
.where(
and(
eq(toolCatalogEntries.companyId, companyId),
eq(toolCatalogEntries.status, "active"),
),
),
db
.select({ id: agents.id })
.from(agents)
.where(eq(agents.companyId, companyId)),
db
.select({ id: toolApplications.id, name: toolApplications.name })
.from(toolApplications)
.where(eq(toolApplications.companyId, companyId)),
db
.select({ id: toolConnections.id, name: toolConnections.name })
.from(toolConnections)
.where(eq(toolConnections.companyId, companyId)),
]);
const entriesByProfile = new Map<
string,
Array<typeof toolProfileEntries.$inferSelect>
>();
const bindingsByProfile = new Map<
string,
Array<typeof toolProfileBindings.$inferSelect>
>();
for (const entry of entries) {
const list = entriesByProfile.get(entry.profileId) ?? [];
list.push(entry);
entriesByProfile.set(entry.profileId, list);
}
for (const binding of bindings) {
const list = bindingsByProfile.get(binding.profileId) ?? [];
list.push(binding);
bindingsByProfile.set(binding.profileId, list);
}
const agentIds = companyAgents.map((agent) => agent.id);
const applicationsById = new Map(
applications.map((application) => [application.id, application]),
);
const connectionsById = new Map(
connections.map((connection) => [connection.id, connection]),
);
return profiles.map((profile) =>
buildProfileDetails({
profile,
entries: entriesByProfile.get(profile.id) ?? [],
bindings: bindingsByProfile.get(profile.id) ?? [],
catalog,
agentIds,
applicationsById,
connectionsById,
}),
);
},
createProfile: async (
companyId: string,
input: CreateToolProfileWithEntries,
): Promise<ToolProfileWithDetails> => {
await assertProfileEntryInputs(db, companyId, input.entries ?? []);
const [row] = await db
.insert(toolProfiles)
.values({
companyId,
profileKey: input.profileKey,
name: input.name,
description: input.description ?? null,
status: input.status ?? "active",
defaultAction: input.defaultAction ?? "deny",
metadata: input.metadata ?? {},
})
.returning();
await createProfileEntries(companyId, row.id, input.entries ?? []);
return profileDetails(row.id, companyId);
},
getProfile: profileDetails,
listProfileNewTools,
reviewProfileNewTools,
updateProfile: async (
profileId: string,
input: UpdateToolProfileWithEntries,
): Promise<ToolProfileWithDetails> => {
const existing = await getProfileRow(profileId);
if (input.entries) {
await assertProfileEntryInputs(db, existing.companyId, input.entries);
}
const referencesChanged = new Error("Tool profile references changed");
for (let attempt = 0; attempt < 3; attempt++) {
try {
await db.transaction(async tx => {
const db = tx as unknown as Db;
const readReferences = () => tx.select({ applicationId: toolProfileEntries.applicationId, connectionId: toolProfileEntries.connectionId, catalogEntryId: toolProfileEntries.catalogEntryId })
.from(toolProfileEntries).where(and(eq(toolProfileEntries.companyId, existing.companyId), eq(toolProfileEntries.profileId, profileId)))
.orderBy(toolProfileEntries.id);
const oldReferences = input.entries ? await readReferences() : [];
await lockProfileEntryReferences(tx, existing.companyId, [...oldReferences, ...(input.entries ?? [])]);
const [current] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
if (!current) throw notFound("Tool profile not found");
if (input.entries && JSON.stringify(await readReferences()) !== JSON.stringify(oldReferences)) throw referencesChanged;
await db
.update(toolProfiles)
.set({
profileKey: input.profileKey ?? current.profileKey,
name: input.name ?? current.name,
description: input.description ?? current.description,
status: input.status ?? current.status,
defaultAction: input.defaultAction ?? current.defaultAction,
metadata: input.metadata ?? current.metadata,
updatedAt: new Date(),
})
.where(eq(toolProfiles.id, profileId));
if (input.entries) {
await replaceProfileEntries(
existing.companyId,
profileId,
input.entries,
tx,
);
}
});
break;
} catch (error) {
if (error !== referencesChanged) throw error;
if (attempt >= 2) throw conflict("Tool profile entries changed during replacement", { retryable: true });
}
}
return profileDetails(profileId, existing.companyId);
},
duplicateProfile: async (
profileId: string,
input: DuplicateToolProfile,
): Promise<ToolProfileWithDetails> => {
const existing = await getProfileRow(profileId);
const [entries, bindings] = await Promise.all([
db
.select()
.from(toolProfileEntries)
.where(
and(
eq(toolProfileEntries.companyId, existing.companyId),
eq(toolProfileEntries.profileId, existing.id),
),
)
.orderBy(asc(toolProfileEntries.createdAt)),
db
.select()
.from(toolProfileBindings)
.where(
and(
eq(toolProfileBindings.companyId, existing.companyId),
eq(toolProfileBindings.profileId, existing.id),
),
)
.orderBy(
asc(toolProfileBindings.priority),
asc(toolProfileBindings.createdAt),
),
]);
const [created] = await db
.insert(toolProfiles)
.values({
companyId: existing.companyId,
profileKey: normalizeKey(`${input.name}-${randomUUID().slice(0, 8)}`),
name: input.name,
description: existing.description,
status: "active",
defaultAction: existing.defaultAction,
newToolsReviewedAt: existing.newToolsReviewedAt,
metadata: existing.metadata ?? {},
})
.returning();
if (entries.length > 0) {
await db.insert(toolProfileEntries).values(
entries.map((entry) => ({
companyId: entry.companyId,
profileId: created.id,
selectorType: entry.selectorType,
effect: entry.effect,
applicationId: entry.applicationId,
connectionId: entry.connectionId,
catalogEntryId: entry.catalogEntryId,
toolName: entry.toolName,
riskLevel: entry.riskLevel,
conditions: entry.conditions,
})),
);
}
if (input.includeAssignments && bindings.length > 0) {
await db.insert(toolProfileBindings).values(
bindings.map((binding) => ({
companyId: binding.companyId,
profileId: created.id,
targetType: binding.targetType,
targetId: binding.targetId,
priority: binding.priority,
metadata: binding.metadata ?? {},
createdByAgentId: binding.createdByAgentId,
createdByUserId: binding.createdByUserId,
})),
);
}
return profileDetails(created.id, existing.companyId);
},
deleteProfile: async (
profileId: string,
input: DeleteToolProfile,
): Promise<{
profile: ToolProfile;
summary: ToolProfileSummary;
reassignedToProfileId: string | null;
reassignedBindingCount: number;
}> => db.transaction(async tx => {
const existing = await getProfileRow(profileId, undefined, tx);
const db = tx as unknown as Db;
await tx.select({ id: toolProfiles.id }).from(toolProfiles)
.where(and(eq(toolProfiles.companyId, existing.companyId), inArray(toolProfiles.id, [profileId, ...(input.reassignToProfileId ? [input.reassignToProfileId] : [])])))
.orderBy(toolProfiles.id).for("update");
await lockToolAssignmentAgents(tx, existing.companyId, [{ targetType: "company", targetId: existing.companyId }]);
if (input.force && input.reassignToProfileId) {
throw badRequest(
"Use either force or reassignToProfileId when deleting a tool profile, not both",
);
}
const details = await profileDetails(existing.id, existing.companyId, tx);
if (
details.summary.isCompanyDefault &&
!input.force &&
!input.reassignToProfileId
) {
throw unprocessable(
"Cannot delete the company default tool profile. Reassign the default profile or pass force=true to delete it.",
{ summary: details.summary },
);
}
let reassignedBindingCount = 0;
if (input.reassignToProfileId) {
if (input.reassignToProfileId === existing.id) {
throw badRequest(
"reassignToProfileId must reference a different tool profile",
);
}
const target = await getProfileRow(
input.reassignToProfileId,
existing.companyId,
tx,
);
if (target.status !== "active") {
throw unprocessable(
"Tool profile assignments can only be reassigned to an active profile",
);
}
const targetBindings = await db
.select()
.from(toolProfileBindings)
.where(
and(
eq(toolProfileBindings.companyId, existing.companyId),
eq(toolProfileBindings.profileId, target.id),
),
);
const targetKeys = new Set(
targetBindings.map(
(binding) => `${binding.targetType}:${binding.targetId}`,
),
);
const copiedBindings = details.bindings.filter(
(binding) =>
!targetKeys.has(`${binding.targetType}:${binding.targetId}`),
);
if (copiedBindings.length > 0) {
await db.insert(toolProfileBindings).values(
copiedBindings.map((binding) => ({
companyId: binding.companyId,
profileId: target.id,
targetType: binding.targetType,
targetId: binding.targetId,
priority: binding.priority,
metadata: binding.metadata ?? {},
createdByAgentId: binding.createdByAgentId,
createdByUserId: binding.createdByUserId,
})),
);
reassignedBindingCount = copiedBindings.length;
await db
.update(toolProfiles)
.set({ updatedAt: new Date() })
.where(eq(toolProfiles.id, target.id));
}
}
const [deleted] = await db
.delete(toolProfiles)
.where(eq(toolProfiles.id, existing.id))
.returning();
if (!deleted) throw notFound("Tool profile not found");
return {
profile: toProfile(deleted),
summary: details.summary,
reassignedToProfileId: input.reassignToProfileId ?? null,
reassignedBindingCount,
};
}),
addProfileEntry: async (
profileId: string,
input: CreateToolProfileEntryForProfile,
): Promise<ToolProfileEntry> => db.transaction(async tx => {
const db = tx as unknown as Db;
const profile = await getProfileRow(profileId, undefined, tx);
await assertProfileEntryInputs(tx, profile.companyId, [input]);
await lockProfileEntryReferences(tx, profile.companyId, [input]);
await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, profile.id)).for("no key update");
const [row] = await db
.insert(toolProfileEntries)
.values({
companyId: profile.companyId,
profileId: profile.id,
selectorType: input.selectorType,
effect: input.effect ?? "include",
applicationId: input.applicationId ?? null,
connectionId: input.connectionId ?? null,
catalogEntryId: input.catalogEntryId ?? null,
toolName: input.toolName ?? null,
riskLevel: input.riskLevel ?? null,
conditions: input.conditions ?? null,
})
.returning();
await db
.update(toolProfiles)
.set({ updatedAt: new Date() })
.where(eq(toolProfiles.id, profile.id));
return toProfileEntry(row);
}),
getProfileEntry: async (entryId: string): Promise<ToolProfileEntry> => {
const [row] = await db
.select()
.from(toolProfileEntries)
.where(eq(toolProfileEntries.id, entryId));
if (!row) throw notFound("Tool profile entry not found");
return toProfileEntry(row);
},
updateProfileEntry: async (
entryId: string,
input: UpdateToolProfileEntry,
): Promise<ToolProfileEntry> => db.transaction(async tx => {
const db = tx as unknown as Db;
const [existing] = await db
.select()
.from(toolProfileEntries)
.where(eq(toolProfileEntries.id, entryId));
if (!existing) throw notFound("Tool profile entry not found");
const next: CreateToolProfileEntryForProfile = {
selectorType: input.selectorType ?? existing.selectorType,
effect: input.effect ?? existing.effect,
applicationId: input.applicationId ?? existing.applicationId,
connectionId: input.connectionId ?? existing.connectionId,
catalogEntryId: input.catalogEntryId ?? existing.catalogEntryId,
toolName: input.toolName ?? existing.toolName,
riskLevel: input.riskLevel ?? existing.riskLevel,
conditions: input.conditions ?? existing.conditions,
};
await assertProfileEntryInputs(tx, existing.companyId, [next]);
await lockProfileEntryReferences(tx, existing.companyId, [next]);
await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, existing.profileId)).for("no key update");
const [row] = await db
.update(toolProfileEntries)
.set({
selectorType: next.selectorType,
effect: next.effect ?? "include",
applicationId: next.applicationId ?? null,
connectionId: next.connectionId ?? null,
catalogEntryId: next.catalogEntryId ?? null,
toolName: next.toolName ?? null,
riskLevel: next.riskLevel ?? null,
conditions: next.conditions ?? null,
updatedAt: new Date(),
})
.where(eq(toolProfileEntries.id, entryId))
.returning();
await db
.update(toolProfiles)
.set({ updatedAt: new Date() })
.where(eq(toolProfiles.id, existing.profileId));
return toProfileEntry(row);
}),
deleteProfileEntry: async (entryId: string): Promise<ToolProfileEntry> => db.transaction(async tx => {
const db = tx as unknown as Db;
const [existing] = await tx.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, entryId));
if (!existing) throw notFound("Tool profile entry not found");
await tx.select({ id: toolProfiles.id }).from(toolProfiles).where(eq(toolProfiles.id, existing.profileId)).for("no key update");
const [row] = await db
.delete(toolProfileEntries)
.where(eq(toolProfileEntries.id, entryId))
.returning();
if (!row) throw notFound("Tool profile entry not found");
await db
.update(toolProfiles)
.set({ updatedAt: new Date() })
.where(eq(toolProfiles.id, row.profileId));
return toProfileEntry(row);
}),
bindProfile: async (
profileId: string,
input: CreateToolProfileBindingForProfile,
actor?: ActorInfo,
): Promise<ToolProfileBinding> => db.transaction(async tx => {
const db = tx as unknown as Db;
const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
if (!profile) throw notFound("Tool profile not found");
await assertTargetExists(
profile.companyId,
input.targetType,
input.targetId,
tx,
);
const [row] = await db
.insert(toolProfileBindings)
.values({
companyId: profile.companyId,
profileId: profile.id,
targetType: input.targetType,
targetId: input.targetId,
priority: input.priority ?? 100,
metadata: input.metadata ?? {},
createdByAgentId:
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
createdByUserId:
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
})
.returning();
await db
.update(toolProfiles)
.set({ updatedAt: new Date() })
.where(eq(toolProfiles.id, profile.id));
return toProfileBinding(row);
}),
unbindProfile: async (
profileId: string,
input: UnbindToolProfileBinding,
): Promise<{ unbound: number }> => db.transaction(async tx => {
const db = tx as unknown as Db;
const [profile] = await tx.select().from(toolProfiles).where(eq(toolProfiles.id, profileId)).for("no key update");
if (!profile) throw notFound("Tool profile not found");
await assertTargetExists(
profile.companyId,
input.targetType,
input.targetId,
tx,
);
const rows = await db
.delete(toolProfileBindings)
.where(
and(
eq(toolProfileBindings.companyId, profile.companyId),
eq(toolProfileBindings.profileId, profile.id),
eq(toolProfileBindings.targetType, input.targetType),
eq(toolProfileBindings.targetId, input.targetId),
),
)
.returning({ id: toolProfileBindings.id });
if (rows.length > 0) {
await db
.update(toolProfiles)
.set({ updatedAt: new Date() })
.where(eq(toolProfiles.id, profile.id));
}
return { unbound: rows.length };
})
};
}
+22
View File
@@ -0,0 +1,22 @@
import type { Db } from "@paperclipai/db";
export type ActorInfo = {
actorType?: "agent" | "user" | "system" | "plugin";
actorId?: string | null;
sessionId?: string | null;
actorSource?:
| "local_implicit"
| "session"
| "board_key"
| "agent_key"
| "agent_jwt"
| "mcp_oauth"
| "cloud_tenant";
};
export type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
export type ToolAccessMutationDb = Pick<
Db | DbTransaction,
"select" | "insert" | "update" | "delete"
>;