Files
PaperClipAI/server/src/routes/tool-access.ts
T
DottaandPaperclip ba603abace fix(apps): guide generic MCP connection failures
Map OAuth origin, duplicate-name, and DNS failures to stable operator guidance, and derive collision-resistant defaults from host, port, and path.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-08-20 21:20:02 +00:00

1434 lines
57 KiB
TypeScript

import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import { agents, companies } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import {
CONNECTABLE_APP_DEFINITIONS,
DEFAULT_OWNERSHIP_AVAILABILITY,
TOOL_ACTION_REQUEST_STATUSES,
type DeploymentExposure,
type DeploymentMode,
type PermissionKey,
connectToolAppSchema,
createToolStdioCommandTemplateSchema,
createToolApplicationSchema,
createToolConnectionSchema,
createToolPolicySchema,
createToolProfileBindingForProfileSchema,
createToolProfileEntryForProfileSchema,
createToolProfileWithEntriesSchema,
deleteToolProfileSchema,
duplicateToolPolicySchema,
disableToolStdioCommandTemplateSchema,
duplicateToolProfileSchema,
finishToolAppSchema,
reconnectToolAppSchema,
reviewToolProfileNewToolsSchema,
createToolTrustRuleFromActionRequestSchema,
importMcpJsonSchema,
putToolConnectionInstallsSchema,
connectionTokenRequestSchema,
startConnectionAuthorizationSchema,
revokeToolTrustRuleSchema,
reorderToolPoliciesSchema,
toolPolicyTestRequestSchema,
toolConnectionTestCallSchema,
unbindToolProfileBindingSchema,
updateToolApplicationSchema,
updateToolConnectionSchema,
updateToolPolicySchema,
updateToolProfileEntrySchema,
updateToolProfileWithEntriesSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { getActorInfo, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js";
import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { accessService, googleSheetsRobotEmailFromEnv, logActivity, toolAccessPolicyService, toolAccessService } from "../services/index.js";
import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js";
import {
OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH,
oauthClientIdMetadataDocument,
} from "../services/tool-access.js";
/** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */
function allowlistIds(config: Record<string, unknown> | null | undefined): string[] {
const raw = config?.allowedSpreadsheetIds;
if (!Array.isArray(raw)) return [];
return raw.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
}
/**
* Classify a connection PATCH into operator-visible lifecycle events so the
* per-app Activity tab can humanize them (PAP-11284). A single update may
* touch more than one thing (e.g. pause + allowlist), so this returns a list.
*/
function classifyConnectionUpdate(
before: { enabled: boolean; config?: Record<string, unknown> | null },
after: { enabled: boolean; config?: Record<string, unknown> | null },
): Array<{ lifecycle: "paused" | "resumed" | "allowlist_changed"; details: Record<string, unknown> }> {
const events: Array<{ lifecycle: "paused" | "resumed" | "allowlist_changed"; details: Record<string, unknown> }> = [];
if (before.enabled !== after.enabled) {
events.push({ lifecycle: after.enabled ? "resumed" : "paused", details: { enabled: after.enabled } });
}
const beforeIds = allowlistIds(before.config);
const afterIds = allowlistIds(after.config);
const beforeSet = new Set(beforeIds);
const afterSet = new Set(afterIds);
const added = afterIds.filter((id) => !beforeSet.has(id)).length;
const removed = beforeIds.filter((id) => !afterSet.has(id)).length;
if (added > 0 || removed > 0) {
events.push({ lifecycle: "allowlist_changed", details: { added, removed, total: afterIds.length } });
}
return events;
}
export function toolAccessRoutes(
db: Db,
options: {
deploymentMode?: DeploymentMode;
deploymentExposure?: DeploymentExposure;
trustedLocalStdioRuntimeHost?: string | null;
toolGateway?: ToolGatewayService;
} = {},
) {
const router = Router();
const svc = toolAccessService(db, options);
const policySvc = toolAccessPolicyService(db);
function configuredPublicBaseUrl() {
const raw = (
process.env.PAPERCLIP_PUBLIC_URL?.trim()
|| process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim()
|| process.env.BETTER_AUTH_URL?.trim()
|| process.env.BETTER_AUTH_BASE_URL?.trim()
);
if (!raw) return null;
try {
return new URL(raw).origin;
} catch {
return null;
}
}
function oauthRedirectUri() {
const configured = configuredPublicBaseUrl();
if (!configured) {
throw unprocessable(
"This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.",
{ code: "oauth_redirect_origin_unsupported" },
);
}
return new URL("/api/tools/oauth/callback", configured).toString();
}
const access = accessService(db);
async function assertBoardToolPermission(req: Request, companyId: string, permissionKey: PermissionKey) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const userId = req.actor.userId;
if (userId && await access.hasPermission(companyId, "user", userId, permissionKey)) return;
throw forbidden(`Missing permission: ${permissionKey}`);
}
async function assertBoardAnyToolPermission(req: Request, companyId: string, permissionKeys: PermissionKey[]) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const userId = req.actor.userId;
if (userId) {
for (const permissionKey of permissionKeys) {
if (await access.hasPermission(companyId, "user", userId, permissionKey)) return;
}
}
throw forbidden(`Missing one of permissions: ${permissionKeys.join(", ")}`);
}
async function assertCanTestAsAgent(req: Request, companyId: string, agentId: string) {
const decision = await access.decide({
actor: req.actor,
action: "tasks:assign",
resource: {
type: "issue",
companyId,
issueId: null,
projectId: null,
parentIssueId: null,
assigneeAgentId: agentId,
assigneeUserId: null,
},
scope: {
assigneeAgentId: agentId,
assigneeUserId: null,
},
});
if (decision.allowed) return;
throw forbidden(decision.explanation);
}
function sendToolGatewayError(res: import("express").Response, error: unknown) {
if (error instanceof ToolGatewayHttpError) {
res.status(error.status).json({ error: error.message, reasonCode: error.reasonCode, ...error.details });
return true;
}
return false;
}
async function assertToolsAdmin(req: Request, companyId: string) {
await assertBoardToolPermission(req, companyId, "tools:admin");
}
async function assertToolsRuntimeManage(req: Request, companyId: string) {
await assertBoardToolPermission(req, companyId, "tools:manage_runtime");
}
router.post("/agents/me/connections/:connectionId/start-authorization", validate(startConnectionAuthorizationSchema), async (req, res) => {
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId || !req.actor.runId) {
res.status(401).json({ error: "Active agent run authentication required" });
return;
}
const result = await svc.startAuthorizationForAgent({
companyId: req.actor.companyId,
connectionId: req.params.connectionId as string,
agentId: req.actor.agentId,
runId: req.actor.runId,
subjectUserId: req.body.subjectUserId,
scopes: req.body.scopes,
returnTo: req.body.returnTo,
redirectUri: oauthRedirectUri(),
});
res.json({ url: result.authorizationUrl });
});
router.post("/agents/me/connections/:connectionId/token", validate(connectionTokenRequestSchema), async (req, res) => {
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
res.status(401).json({ error: "Agent authentication required" });
return;
}
if (!req.actor.runId) {
res.status(401).json({ error: "Agent run id required", code: "run_id_required" });
return;
}
const headerRunId = req.get("X-Paperclip-Run-Id")?.trim();
if (headerRunId && headerRunId !== req.actor.runId) {
res.status(403).json({ error: "Run id header does not match agent token", code: "run_id_mismatch" });
return;
}
const result = await svc.mintConnectionTokenForAgent({
connectionId: req.params.connectionId as string,
companyId: req.actor.companyId,
agentId: req.actor.agentId,
runId: req.actor.runId,
body: req.body,
});
res.status(result.status === "use_env_lease" ? 409 : 200).json(result);
});
function assertToolAppMutationAccess(req: Request, companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const membership = Array.isArray(req.actor.memberships)
? req.actor.memberships.find((item) => item.companyId === companyId)
: null;
if (!membership || membership.status !== "active") {
throw forbidden("User does not have active company access");
}
if (!membership.membershipRole || membership.membershipRole === "viewer") {
throw forbidden("Viewer access is read-only");
}
}
router.get("/companies/:companyId/tools/gallery", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const googleSheetsAvailability = googleSheetsRobotEmailFromEnv();
res.json({
apps: CONNECTABLE_APP_DEFINITIONS.map((app) =>
app.slug === "google-sheets"
? {
...app,
ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY,
availability: googleSheetsAvailability.available
? { available: true, robotEmail: googleSheetsAvailability.robotEmail }
: { available: false, reason: googleSheetsAvailability.reason },
}
: { ...app, ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY },
),
});
});
/**
* Paperclip's Client ID Metadata Document (PAP-17087).
*
* The document's own URL is the `client_id` Paperclip presents to an
* authorization server that supports CIMD, so this endpoint has to be publicly
* readable — an authorization server fetches it server-to-server with no
* Paperclip session. It contains only this deployment's callback and the
* grant/response/auth methods Paperclip uses: no company, connection or secret
* data of any kind.
*/
router.get(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH.replace(/^\/api/, ""), (_req, res) => {
const redirectUri = oauthRedirectUri();
const clientId = new URL(OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, new URL(redirectUri).origin).toString();
res.type("application/json").json(oauthClientIdMetadataDocument({ clientId, redirectUri }));
});
router.post("/companies/:companyId/tools/apps/connect", validate(connectToolAppSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req));
if (result.auth?.kind === "oauth") {
try {
const start = await svc.startOAuth(companyId, result.connectionId, {
redirectUri: oauthRedirectUri(),
actor: getActorInfo(req),
});
result.auth.startUrl = start.authorizationUrl;
result.auth.issuer = start.issuer ?? result.auth.issuer ?? null;
result.auth.resource = start.resource ?? result.auth.resource ?? null;
result.auth.registrationSource = start.registrationSource ?? null;
} catch (error) {
// An unknown server whose authorization server supports neither CIMD
// nor dynamic registration is not a failed connect: the draft
// connection is real and usable as soon as the operator supplies a
// client they registered themselves. Report that instead of a 4xx so
// the wizard can ask for it rather than losing the draft.
const code = error instanceof HttpError ? String((error.details as { code?: unknown })?.code ?? "") : "";
if (code !== "oauth_manual_client_required" && code !== "oauth_manual_client_rebinding_required") throw error;
result.auth.startUrl = null;
result.auth.manualClientRequired = true;
}
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_app.connected",
entityType: "tool_connection",
entityId: result.connectionId,
details: {
galleryKey: req.body.galleryKey ?? null,
link: req.body.link ?? null,
applicationId: result.application.id,
catalogEntryCount: result.catalog.length,
readOnlyActionCount: result.actions.readOnly.length,
canMakeChangesActionCount: result.actions.canMakeChanges.length,
},
});
res.status(201).json(result);
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
});
router.post(
"/companies/:companyId/tools/connections/:connectionId/start-authorization",
validate(startConnectionAuthorizationSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
if (!req.actor.userId || req.actor.userId !== req.body.subjectUserId) {
throw forbidden("Board users may only authorize their own connection subject");
}
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
const result = await svc.startOAuth(companyId, existing.id, {
redirectUri: oauthRedirectUri(),
actor: getActorInfo(req),
subjectUserId: req.body.subjectUserId,
scopes: req.body.scopes,
returnTo: req.body.returnTo,
});
res.json({ url: result.authorizationUrl });
},
);
router.post("/tools/oauth/:connectionId/start", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const result = await svc.startOAuth(existing.companyId, existing.id, {
redirectUri: oauthRedirectUri(),
actor: getActorInfo(req),
});
res.json(result);
});
router.get("/tools/oauth/callback", async (req, res) => {
assertBoard(req);
const state = typeof req.query.state === "string" ? req.query.state : "";
const code = typeof req.query.code === "string" ? req.query.code : null;
const error = typeof req.query.error === "string" ? req.query.error : null;
// `error_description` / `error_uri` are read from neither the query nor the
// provider's body: they are provider-authored prose, and Paperclip maps the
// `error` code to its own copy instead of reflecting them (PAP-17108).
const iss = typeof req.query.iss === "string" ? req.query.iss : null;
const pendingState = state ? await svc.peekOAuthState(state) : null;
if (!pendingState || !hasCompanyAccess(req, pendingState.companyId)) {
throw badRequest("Invalid or expired OAuth state");
}
assertToolAppMutationAccess(req, pendingState.companyId);
const result = await svc.completeOAuthCallback({
state,
code,
error,
iss,
redirectUri: oauthRedirectUri(),
actor: getActorInfo(req),
});
await logActivity(db, {
companyId: result.connection.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_app.oauth_connected",
entityType: "tool_connection",
entityId: result.connection.id,
details: {
applicationId: result.application.id,
catalogEntryCount: result.catalog.length,
},
});
if (req.get("accept")?.includes("text/html")) {
const [company] = await db
.select({ issuePrefix: companies.issuePrefix })
.from(companies)
.where(eq(companies.id, result.connection.companyId))
.limit(1);
if (!company) throw new Error("OAuth callback connection belongs to a missing company");
res.redirect(303, `/${company.issuePrefix}/apps/${result.connection.id}/setup?oauth=connected`);
return;
}
res.json(result);
});
router.post("/companies/:companyId/tools/apps/:connectionId/finish", validate(finishToolAppSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const existing = await svc.getConnection(req.params.connectionId as string, companyId);
const result = await svc.finishGalleryAppConnection(companyId, existing.id, req.body, getActorInfo(req));
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_app.finished",
entityType: "tool_connection",
entityId: result.connection.id,
details: {
profileId: result.profile.id,
profileEntryCount: result.profileEntries.length,
profileBindingCount: result.profileBindings.length,
askFirstPolicyCount: result.policies.length,
reviewedCatalogEntryCount: req.body.reviewedCatalogEntryIds?.length ?? 0,
access: req.body.access,
},
});
res.json(result);
});
router.get("/companies/:companyId/tools/examples", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json({ examples: await svc.listExamples(companyId) });
});
router.get("/companies/:companyId/tools/apps/attention", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.listAppsNeedingAttention(companyId));
});
router.get("/companies/:companyId/tools/action-requests", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const statusRaw = typeof req.query.status === "string" ? req.query.status : "pending";
const status = (TOOL_ACTION_REQUEST_STATUSES as readonly string[]).includes(statusRaw)
? (statusRaw as (typeof TOOL_ACTION_REQUEST_STATUSES)[number])
: "pending";
res.json({ actionRequests: await svc.listActionRequests(companyId, status) });
});
router.post("/companies/:companyId/tools/examples/:id/install", async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const result = await svc.installExample(companyId, req.params.id as string, getActorInfo(req));
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_example.installed",
entityType: "tool_example",
entityId: result.example.id,
details: {
created: result.created,
applicationId: result.application.id,
connectionId: result.connection.id,
profileId: result.profile.id,
profileEntryCount: result.profileEntries.length,
},
});
res.status(result.created ? 201 : 200).json(result);
});
router.post("/companies/:companyId/tools/examples/:id/smoke", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.smokeExample(companyId, req.params.id as string, getActorInfo(req));
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_example.smoke_run",
entityType: "tool_example",
entityId: result.exampleId,
details: {
ok: result.ok,
actor: result.actor,
connectionId: result.connection.id,
profileId: result.profile.id,
checks: result.checks.map((check) => ({
name: check.name,
ok: check.ok,
toolName: check.toolName ?? null,
decision: check.decision ?? null,
reasonCode: check.reasonCode ?? null,
})),
},
});
res.json(result);
});
router.get("/companies/:companyId/tools/applications", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json({ applications: await svc.listApplications(companyId) });
});
router.post("/companies/:companyId/tools/applications", validate(createToolApplicationSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
const application = await svc.createApplication(companyId, req.body);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_application.created",
entityType: "tool_application",
entityId: application.id,
details: { type: application.type, name: application.name },
});
res.status(201).json(application);
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
});
router.patch("/tool-applications/:applicationId", validate(updateToolApplicationSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getApplication(req.params.applicationId as string), "Tool application not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
try {
const application = await svc.updateApplication(existing.id, req.body);
await logActivity(db, {
companyId: application.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_application.updated",
entityType: "tool_application",
entityId: application.id,
details: { status: application.status, name: application.name },
});
res.json(application);
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
});
router.delete("/tool-applications/:applicationId", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getApplication(req.params.applicationId as string), "Tool application not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const application = await svc.deleteApplication(existing.id);
await logActivity(db, {
companyId: application.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_application.deleted",
entityType: "tool_application",
entityId: application.id,
details: { type: application.type, name: application.name },
});
res.json(application);
});
router.get("/companies/:companyId/tools/connections", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json({ connections: await svc.listConnections(companyId) });
});
router.post("/companies/:companyId/tools/connections", validate(createToolConnectionSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
const connection = await svc.createConnection(companyId, req.body);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_connection.created",
entityType: "tool_connection",
entityId: connection.id,
details: {
transport: connection.transport,
status: connection.status,
enabled: connection.enabled,
credentialRefCount: (connection.credentialRefs ?? []).length + connection.credentialSecretRefs.length,
},
});
res.status(201).json(connection);
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
});
router.get("/tool-connections/:connectionId", async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
res.json(connection);
});
router.get("/tool-connections/:connectionId/grants", async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
res.json(await svc.listConnectionGrants(connection.id, connection.companyId));
});
router.post("/tool-connections/:connectionId/grants/installations", async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections");
const body = req.body && typeof req.body === "object" ? req.body as Record<string, unknown> : {};
const credentialSecretRefs = Array.isArray(body.credentialSecretRefs) ? body.credentialSecretRefs : [];
const providerTenant = body.providerTenant && typeof body.providerTenant === "object"
? body.providerTenant as { name?: string; externalId?: string }
: undefined;
const grant = await svc.addConnectionInstallation(connection.id, {
providerTenant,
credentialSecretRefs,
isDefault: body.isDefault === true,
}, getActorInfo(req));
await logActivity(db, {
companyId: connection.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_connection.grant_added",
entityType: "connection_grant",
entityId: grant.id,
details: { connectionId: connection.id, kind: grant.kind },
});
res.status(201).json(grant);
});
router.delete("/tool-connections/:connectionId/grants/:grantId", async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections");
const grant = await svc.revokeConnectionGrant(connection.id, req.params.grantId as string, getActorInfo(req));
await logActivity(db, {
companyId: connection.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_connection.grant_revoked",
entityType: "connection_grant",
entityId: grant.id,
details: { connectionId: connection.id, kind: grant.kind },
});
res.json(grant);
});
router.get("/tool-connections/:connectionId/usage", async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
const range = req.query.range === "30d" ? "30d" : req.query.range === undefined || req.query.range === "7d" ? "7d" : null;
if (!range) throw badRequest("Usage range must be 7d or 30d");
res.json(await svc.getConnectionUsage(connection.id, range, connection.companyId));
});
router.get("/tool-connections/:connectionId/installs", async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
res.json({ connectionId: connection.id, installs: connection.installs ?? [] });
});
router.put(
"/tool-connections/:connectionId/installs",
validate(putToolConnectionInstallsSchema),
async (req, res) => {
assertBoard(req);
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertBoardToolPermission(req, connection.companyId, "tools:manage_connections");
const snapshot = await svc.putConnectionInstalls(connection.id, req.body, getActorInfo(req));
await logActivity(db, {
companyId: connection.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_connection.installs_synced",
entityType: "tool_connection",
entityId: connection.id,
details: {
installs: snapshot.installs.map((install) => ({ targetType: install.targetType, targetId: install.targetId })),
},
});
res.json(snapshot);
},
);
router.get("/tool-connections/:connectionId/test-agents", async (req, res) => {
assertBoard(req);
if (!options.toolGateway) {
res.status(501).json({ error: "Tool gateway service is not configured" });
return;
}
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]);
const rows = await db
.select({
id: agents.id,
name: agents.name,
role: agents.role,
title: agents.title,
status: agents.status,
})
.from(agents)
.where(eq(agents.companyId, connection.companyId));
const candidates = [];
for (const agent of rows) {
try {
await assertCanTestAsAgent(req, connection.companyId, agent.id);
} catch {
continue;
}
candidates.push({
...agent,
effectiveAccess: await options.toolGateway.summarizeConnectionAccessForAgent({
companyId: connection.companyId,
connectionId: connection.id,
agentId: agent.id,
}),
});
}
res.json({ agents: candidates });
});
router.post("/tool-connections/:connectionId/test-calls", validate(toolConnectionTestCallSchema), async (req, res) => {
assertBoard(req);
if (!options.toolGateway) {
res.status(501).json({ error: "Tool gateway service is not configured" });
return;
}
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]);
await assertCanTestAsAgent(req, connection.companyId, req.body.agentId);
try {
const result = await options.toolGateway.executeTestCall({
companyId: connection.companyId,
connectionId: connection.id,
agentId: req.body.agentId,
userId: req.actor.userId ?? "board",
toolName: req.body.toolName,
parameters: req.body.parameters ?? {},
});
res.json(result);
} catch (error) {
if (!sendToolGatewayError(res, error)) throw error;
}
});
router.get("/tool-connections/:connectionId/test-calls/:actionRequestId", async (req, res) => {
assertBoard(req);
if (!options.toolGateway) {
res.status(501).json({ error: "Tool gateway service is not configured" });
return;
}
const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!connection) return;
await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]);
try {
const status = await options.toolGateway.getTestCallStatus({
companyId: connection.companyId,
connectionId: connection.id,
actionRequestId: req.params.actionRequestId as string,
});
res.json(status);
} catch (error) {
if (!sendToolGatewayError(res, error)) throw error;
}
});
router.patch("/tool-connections/:connectionId", validate(updateToolConnectionSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const connection = await svc.updateConnection(existing.id, req.body);
const lifecycleChanges = classifyConnectionUpdate(
{ enabled: existing.enabled, config: existing.config },
{ enabled: connection.enabled, config: connection.config },
);
const baseLog = {
companyId: connection.companyId,
actorType: "user" as const,
actorId: req.actor.userId ?? "board",
action: "tool_connection.updated",
entityType: "tool_connection",
entityId: connection.id,
};
if (lifecycleChanges.length === 0) {
await logActivity(db, {
...baseLog,
details: {
status: connection.status,
enabled: connection.enabled,
credentialRefCount: (connection.credentialRefs ?? []).length + connection.credentialSecretRefs.length,
},
});
} else {
// One activity row per lifecycle change so the Activity tab renders one
// humanized row each (PAP-11284), e.g. a combined pause + allowlist edit.
for (const change of lifecycleChanges) {
await logActivity(db, {
...baseLog,
details: {
status: connection.status,
enabled: connection.enabled,
lifecycle: change.lifecycle,
...change.details,
},
});
}
}
res.json(connection);
});
router.delete("/tool-connections/:connectionId", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const applicationBefore = await svc.getApplication(existing.applicationId);
const connection = await svc.archiveConnection(existing.id);
const applicationAfter = await svc.getApplication(existing.applicationId);
await logActivity(db, {
companyId: connection.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_connection.archived",
entityType: "tool_connection",
entityId: connection.id,
details: { transport: connection.transport },
});
if (applicationBefore.status !== "archived" && applicationAfter.status === "archived") {
await logActivity(db, {
companyId: applicationAfter.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_application.archived",
entityType: "tool_application",
entityId: applicationAfter.id,
details: { type: applicationAfter.type, name: applicationAfter.name, reason: "last_connection_removed" },
});
}
res.json(connection);
});
router.post("/tool-connections/:connectionId/health-check", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
res.json(await svc.checkHealth(existing.id, getActorInfo(req)));
});
router.post(
"/tool-connections/:connectionId/reconnect",
validate(reconnectToolAppSchema),
async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const result = await svc.reconnectGalleryApp(
existing.id,
existing.companyId,
req.body,
getActorInfo(req),
);
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_app.reconnected",
entityType: "tool_connection",
entityId: existing.id,
details: { healthStatus: result.connection.healthStatus },
});
res.json(result);
},
);
router.post("/tool-connections/:connectionId/catalog/refresh", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
res.json(await svc.refreshCatalog(existing.id, getActorInfo(req)));
});
router.get("/tool-connections/:connectionId/catalog", async (req, res) => {
assertBoard(req);
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
res.json({ catalog: await svc.listCatalog(existing.id, existing.companyId) });
});
router.get("/tool-connections/:connectionId/activity", async (req, res) => {
assertBoard(req);
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
const limitRaw = Number(req.query.limit ?? 20);
const limit = Number.isFinite(limitRaw) ? limitRaw : 20;
res.json(await svc.listConnectionActivity(existing.id, existing.companyId, limit));
});
router.get("/companies/:companyId/tools/profiles", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json({ profiles: await svc.listProfiles(companyId) });
});
router.get("/tool-profiles/:profileId/new-tools", async (req, res) => {
assertBoard(req);
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
res.json(await svc.listProfileNewTools(existing.id, existing.companyId));
});
router.post("/companies/:companyId/tools/profiles", validate(createToolProfileWithEntriesSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
const profile = await svc.createProfile(companyId, req.body);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile.created",
entityType: "tool_profile",
entityId: profile.id,
details: { name: profile.name, entryCount: profile.entries.length },
});
res.status(201).json(profile);
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
});
router.get("/companies/:companyId/tools/profiles/effective/agents/:agentId", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.getEffectiveProfilesForAgent(companyId, req.params.agentId as string));
});
router.patch("/tool-profiles/:profileId", validate(updateToolProfileWithEntriesSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
try {
const profile = await svc.updateProfile(existing.id, req.body);
await logActivity(db, {
companyId: profile.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile.updated",
entityType: "tool_profile",
entityId: profile.id,
details: { status: profile.status, entryCount: profile.entries.length },
});
res.json(profile);
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
});
router.post("/tool-profiles/:profileId/duplicate", validate(duplicateToolProfileSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
try {
const profile = await svc.duplicateProfile(existing.id, req.body);
await logActivity(db, {
companyId: profile.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile.duplicated",
entityType: "tool_profile",
entityId: profile.id,
details: {
sourceProfileId: existing.id,
name: profile.name,
entryCount: profile.entries.length,
assignmentCount: profile.summary.assignmentCount,
},
});
res.status(201).json(profile);
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
});
router.delete("/tool-profiles/:profileId", validate(deleteToolProfileSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const result = await svc.deleteProfile(existing.id, req.body);
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile.deleted",
entityType: "tool_profile",
entityId: existing.id,
details: {
name: existing.name,
summary: result.summary,
reassignedToProfileId: result.reassignedToProfileId,
reassignedBindingCount: result.reassignedBindingCount,
},
});
res.json(result);
});
router.post("/tool-profiles/:profileId/new-tools/review", validate(reviewToolProfileNewToolsSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const result = await svc.reviewProfileNewTools(existing.id, req.body, getActorInfo(req));
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile.new_tools_reviewed",
entityType: "tool_profile",
entityId: existing.id,
details: {
allowedCount: result.allowedCount,
keptBlockedCount: result.keptBlockedCount,
reviewedCatalogEntryIds: result.reviewedCatalogEntryIds,
},
});
res.json(result);
});
router.post("/tool-profiles/:profileId/entries", validate(createToolProfileEntryForProfileSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const entry = await svc.addProfileEntry(existing.id, req.body);
await logActivity(db, {
companyId: entry.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile_entry.created",
entityType: "tool_profile_entry",
entityId: entry.id,
details: { profileId: entry.profileId, selectorType: entry.selectorType, effect: entry.effect },
});
res.status(201).json(entry);
});
router.patch("/tool-profile-entries/:entryId", validate(updateToolProfileEntrySchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const entry = await svc.updateProfileEntry(existing.id, req.body);
await logActivity(db, {
companyId: entry.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile_entry.updated",
entityType: "tool_profile_entry",
entityId: entry.id,
details: { profileId: entry.profileId, selectorType: entry.selectorType, effect: entry.effect },
});
res.json(entry);
});
router.delete("/tool-profile-entries/:entryId", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found");
if (!existing) return;
assertToolAppMutationAccess(req, existing.companyId);
const entry = await svc.deleteProfileEntry(existing.id);
await logActivity(db, {
companyId: entry.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile_entry.deleted",
entityType: "tool_profile_entry",
entityId: entry.id,
details: { profileId: entry.profileId },
});
res.json(entry);
});
router.post(
"/companies/:companyId/tools/profiles/:profileId/bind",
validate(createToolProfileBindingForProfileSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const existing = await svc.getProfile(req.params.profileId as string, companyId);
try {
const binding = await svc.bindProfile(existing.id, req.body, getActorInfo(req));
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile_binding.created",
entityType: "tool_profile_binding",
entityId: binding.id,
details: { profileId: binding.profileId, targetType: binding.targetType, targetId: binding.targetId },
});
res.status(201).json(binding);
} catch (error) {
svc.ensureNoDuplicateNameError(error);
}
},
);
router.post(
"/companies/:companyId/tools/profiles/:profileId/unbind",
validate(unbindToolProfileBindingSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const existing = await svc.getProfile(req.params.profileId as string, companyId);
const result = await svc.unbindProfile(existing.id, req.body);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_profile_binding.deleted",
entityType: "tool_profile",
entityId: existing.id,
details: { targetType: req.body.targetType, targetId: req.body.targetId, unbound: result.unbound },
});
res.json(result);
},
);
router.get("/companies/:companyId/tools/runtime-slots", async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsRuntimeManage(req, companyId);
res.json({ runtimeSlots: await svc.listRuntimeSlots(companyId) });
});
router.post("/companies/:companyId/tools/runtime-slots/:id/stop", async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsRuntimeManage(req, companyId);
res.json(await svc.stopRuntimeSlot(companyId, req.params.id as string, getActorInfo(req)));
});
router.post("/companies/:companyId/tools/runtime-slots/:id/restart", async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsRuntimeManage(req, companyId);
res.json(await svc.restartRuntimeSlot(companyId, req.params.id as string, getActorInfo(req)));
});
router.get("/companies/:companyId/tools/runtime-health", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.getRuntimeHealth(companyId));
});
router.get("/companies/:companyId/tools/runs/:runId/decisions", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.getRunDecisionLookup(companyId, req.params.runId as string));
});
router.get("/companies/:companyId/tools/trust-rules", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json({ trustRules: await policySvc.listTrustRules(companyId) });
});
router.get("/companies/:companyId/tools/policies", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json({ policies: await policySvc.listPolicies(companyId) });
});
// Rules UI sentence slots map exactly onto policy selectors:
// capability -> riskLevel, app -> applicationId, actions -> toolNames.
router.post("/companies/:companyId/tools/policies/reorder", validate(reorderToolPoliciesSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const policies = await policySvc.reorderPolicies(companyId, req.body);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_policy.reordered",
entityType: "tool_policy",
entityId: companyId,
details: {
policyIds: req.body.policyIds,
priorityStep: 100,
},
});
res.json({ policies });
});
router.post("/companies/:companyId/tools/policies", validate(createToolPolicySchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
const policy = await policySvc.createPolicy(companyId, req.body, { userId: req.actor.userId ?? null });
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_policy.created",
entityType: "tool_policy",
entityId: policy.id,
details: { name: policy.name, policyType: policy.policyType, priority: policy.priority },
});
res.status(201).json(policy);
} catch (error) {
policySvc.ensureNoDuplicatePolicyNameError(error);
}
});
router.post("/companies/:companyId/tools/policies/:policyId/duplicate", validate(duplicateToolPolicySchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
const policy = await policySvc.duplicatePolicy({
companyId,
policyId: req.params.policyId as string,
body: req.body,
actor: { userId: req.actor.userId ?? null },
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_policy.duplicated",
entityType: "tool_policy",
entityId: policy.id,
details: {
sourcePolicyId: req.params.policyId,
name: policy.name,
enabled: policy.enabled,
priority: policy.priority,
},
});
res.status(201).json(policy);
} catch (error) {
policySvc.ensureNoDuplicatePolicyNameError(error);
}
});
router.patch("/companies/:companyId/tools/policies/:policyId", validate(updateToolPolicySchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
try {
const policy = await policySvc.updatePolicy({
companyId,
policyId: req.params.policyId as string,
body: req.body,
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_policy.updated",
entityType: "tool_policy",
entityId: policy.id,
details: { name: policy.name, policyType: policy.policyType, enabled: policy.enabled, priority: policy.priority },
});
res.json(policy);
} catch (error) {
policySvc.ensureNoDuplicatePolicyNameError(error);
}
});
router.delete("/companies/:companyId/tools/policies/:policyId", async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const policy = await policySvc.deletePolicy({
companyId,
policyId: req.params.policyId as string,
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_policy.deleted",
entityType: "tool_policy",
entityId: policy.id,
details: { name: policy.name, policyType: policy.policyType },
});
res.json(policy);
});
router.post(
"/companies/:companyId/tools/action-requests/:actionRequestId/trust-rule",
validate(createToolTrustRuleFromActionRequestSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const policy = await policySvc.createTrustRuleFromActionRequest({
companyId,
actionRequestId: req.params.actionRequestId as string,
body: req.body,
actor: { userId: req.actor.userId ?? null },
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_trust_rule.created",
entityType: "tool_policy",
entityId: policy.id,
details: {
name: policy.name,
selectors: policy.selectors,
sourceActionRequestId: req.params.actionRequestId,
},
});
res.status(201).json(policy);
},
);
router.post("/companies/:companyId/tools/trust-rules/:policyId/revoke", validate(revokeToolTrustRuleSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertToolAppMutationAccess(req, companyId);
const policy = await policySvc.revokeTrustRule({
companyId,
policyId: req.params.policyId as string,
body: req.body,
actor: { userId: req.actor.userId ?? null },
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_trust_rule.revoked",
entityType: "tool_policy",
entityId: policy.id,
details: { reason: req.body.reason ?? null },
});
res.json(policy);
});
router.get("/companies/:companyId/tools/stdio-templates", async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsAdmin(req, companyId);
res.json({ templates: await svc.approvedStdioTemplates(companyId) });
});
router.post("/companies/:companyId/tools/stdio-templates", validate(createToolStdioCommandTemplateSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsAdmin(req, companyId);
const template = await svc.createStdioCommandTemplate(companyId, req.body, getActorInfo(req));
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_stdio_command_template.created",
entityType: "tool_stdio_command_template",
entityId: template.id ?? template.templateId,
details: {
templateId: template.templateId,
command: template.command,
argCount: template.args.length,
envKeyCount: template.envKeys.length,
toolCount: template.tools.length,
},
});
res.status(201).json(template);
});
router.post(
"/companies/:companyId/tools/stdio-templates/:templateId/disable",
validate(disableToolStdioCommandTemplateSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertToolsAdmin(req, companyId);
const template = await svc.disableStdioCommandTemplate(companyId, req.params.templateId as string);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_stdio_command_template.disabled",
entityType: "tool_stdio_command_template",
entityId: template.id ?? template.templateId,
details: { templateId: template.templateId, reason: req.body.reason ?? null },
});
res.json(template);
},
);
router.post("/companies/:companyId/tools/mcp/import-json", validate(importMcpJsonSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const preview = await svc.previewMcpJsonImport(req.body);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "tool_connection.import_mcp_json_previewed",
entityType: "tool_connection_import",
entityId: companyId,
details: { draftCount: preview.drafts.length },
});
res.json(preview);
});
router.post("/companies/:companyId/tools/policy/test", validate(toolPolicyTestRequestSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const input = { ...req.body, companyId };
const decision = await policySvc.decide(input);
let auditEvent = null;
if (input.writeAuditEvent === true) {
auditEvent = await policySvc.writeAudit(input, decision);
}
res.json({ decision, auditEvent });
});
return router;
}