mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
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:
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.
|
||||
+17
-1558
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 };
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
>;
|
||||
Reference in new issue
Block a user