import { createHash, generateKeyPairSync, randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi, } from "vitest"; import { activityLog, agents, authUsers, companies, companyMemberships, companySecretBindings, connectionGrantMembers, connectionGrantDelegations, connectionGrants, connectionTokenIssuances, companySecrets, companySecretVersions, createDb, heartbeatRuns, issueThreadInteractions, issues, principalPermissionGrants, secretAccessEvents, toolAccessAuditEvents, toolActionRequests, toolApplications, toolCallEvents, toolCatalogEntries, toolConnectionInstalls, toolConnections, toolOauthStates, toolInvocations, toolMcpGateways, toolPolicies, toolProfileBindings, toolProfileEntries, toolProfiles, toolRuntimeMetricCounters, toolRuntimeSlots, toolStdioCommandTemplates, userSecretDefinitions, userSecretDeclarations, } from "@paperclipai/db"; import { and, eq, inArray, sql } from "drizzle-orm"; import { APP_STORE_HIDDEN_SLUGS, GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAvailableConnectionMethod, getConnectableAppDefinition, type GoogleWorkspaceConnectorProfileId, } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { classifyRisk, normalizeConnectionMethodConfig, projectConnectionMethodToolInputSchema, projectConnectionMethodToolArguments, toolAccessService, } from "../services/tool-access.js"; import { accessService } from "../services/access.js"; import { toolAccessPolicyService } from "../services/tool-access-policy.js"; import { secretService } from "../services/secrets.js"; import { canonicalToolArguments, signToolArguments, } from "../services/tool-content-guards.js"; import { createToolGatewayService as createToolGatewayServiceBase, type ToolGatewayService, } from "../services/tool-gateway.js"; import { toolAccessRoutes } from "../routes/tool-access.js"; import { errorHandler } from "../middleware/index.js"; import type { ComposioClient } from "../services/composio.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { invalidatePaperclipCloudConnectorCapabilities, type PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; /** * This suite predates the DNS-pinned HTTP transport and deliberately models * remote servers with global fetch fixtures. Keep those protocol fixtures * deterministic while the dedicated rebinding suite exercises real pinning. */ function createTestToolAccessService( db: ReturnType, options: Parameters[1] = {}, ) { return toolAccessService(db, { remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], remoteHttpRequest: async (url, init) => fetch(url, init), ...options, }); } function fakeGoogleWorkspaceConnector( companyId: string, userId: string, profile: GoogleWorkspaceConnectorProfileId = "gmail.draft", ): PaperclipCloudConnector { const profileDefinition = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile]; const tokenPrefix = profile.split(".")[0]!; const credentials = { v: 1 as const, accessToken: `${tokenPrefix}-access-token`, refreshToken: `${tokenPrefix}-refresh-token`, tokenType: "Bearer", accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), scopes: [...profileDefinition.scopes], subject: userId, companyId, instanceId: "test-instance", environment: "development" as const, provider: "google" as const, profile, }; return { getCapabilities: vi.fn(async () => [profile]), startAuthorization: vi.fn(async ({ returnState }) => ({ authorizationUrl: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(returnState)}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), })), claim: vi.fn(async () => credentials), refresh: vi.fn(async () => credentials), revoke: vi.fn(async () => undefined), }; } function fakeGmailConnector( companyId: string, userId: string, ): PaperclipCloudConnector { return fakeGoogleWorkspaceConnector(companyId, userId); } function fakeGitHubConnector( companyId: string, subject: string, ): PaperclipCloudConnector { const credentials = { v: 1 as const, accessToken: "ghu_non_expiring_access_token", refreshToken: null, tokenType: "Bearer", accessTokenExpiresAt: null, refreshTokenExpiresAt: null, scopes: [...GITHUB_CONNECTOR_PROFILES["github.code"].scopes], subject, companyId, instanceId: "test-instance", environment: "development" as const, provider: "github" as const, profile: "github.code" as const, appSlug: "paperclip-development", }; return { getCapabilities: vi.fn(async () => ["github.code" as const]), startAuthorization: vi.fn(async ({ returnState }) => ({ authorizationUrl: `https://github.com/login/oauth/authorize?state=${encodeURIComponent(returnState)}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), })), claim: vi.fn(async () => credentials), refresh: vi.fn(async () => credentials), revoke: vi.fn(async () => undefined), setWebhookBinding: vi.fn(async () => undefined), leaseEvents: vi.fn(async () => null), acknowledgeEvents: vi.fn(async () => 0), }; } function createToolGatewayService( db: ReturnType, options: NonNullable[1]> = {}, ) { return createToolGatewayServiceBase(db, { remoteHttpRequest: async (url, init) => fetch(url, init), ...options, }); } async function createCompany(db: ReturnType) { return db .insert(companies) .values({ name: `Tool Access CRUD ${randomUUID()}`, issuePrefix: `TC${randomUUID().slice(0, 6).toUpperCase()}`, }) .returning() .then((rows) => rows[0]!); } async function createComposioParentAndChild( db: ReturnType, companyId: string, ) { const secrets = secretService(db); const apiKey = await secrets.create(companyId, { name: `Composio test key ${randomUUID().slice(0, 8)}`, key: `tool_app.${randomUUID()}.credentials_apiKey`, provider: "local_encrypted", value: "composio-test-key", }); const [application] = await db .insert(toolApplications) .values({ companyId, name: "Composio", type: "rest_api", status: "active", }) .returning(); const [parent] = await db .insert(toolConnections) .values({ companyId, applicationId: application!.id, name: "Composio", uid: `composio/${randomUUID()}`, transport: "rest_api", authKind: "api_key", status: "active", enabled: true, config: { sourceTemplateKey: "composio" }, transportConfig: { sourceTemplateKey: "composio" }, credentialRefs: [ { name: "credentials.apiKey", secretId: apiKey.id, version: "latest", placement: "header", key: "x-api-key", prefix: null, }, ], credentialSecretRefs: [ { secretId: apiKey.id, versionSelector: "latest", configPath: "credentials.apiKey", required: true, label: "Composio API key", }, ], }) .returning(); await db.insert(companySecretBindings).values({ companyId, secretId: apiKey.id, targetType: "tool_connection", targetId: parent!.id, configPath: "credentials.apiKey", }); const [child] = await db .insert(toolConnections) .values({ companyId, applicationId: application!.id, name: "GitHub (via Composio)", uid: `composio/github/${randomUUID()}`, transport: "mcp_remote", authKind: "none", status: "active", enabled: true, config: { provider: "composio", parentConnectionId: parent!.id, toolkitSlug: "github", connectedAccountId: "account-github", }, transportConfig: {}, }) .returning(); return { parent: parent!, child: child! }; } function fakeComposioClient(accountStatus: () => string): ComposioClient { return { validateApiKey: vi.fn(async () => undefined), listToolkits: vi.fn(async () => ({ items: [{ slug: "github", name: "GitHub" }], })), listAuthConfigs: vi.fn(async () => ({ items: [] })), createConnectLink: vi.fn(async () => ({ link_token: "link", redirect_url: "https://composio.test/link", expires_at: new Date().toISOString(), })), listConnectedAccounts: vi.fn(async () => ({ items: [ { id: "account-github", user_id: "paperclip:test", status: accountStatus(), toolkit: { slug: "github" }, auth_config: { id: "auth-github", auth_scheme: "OAUTH2", is_composio_managed: true, }, }, ], })), deleteConnectedAccount: vi.fn(async () => undefined), createSession: vi.fn(async () => ({ session_id: "session", mcp: { url: "https://composio.test/mcp" }, })), resumeSession: vi.fn(async () => ({ session_id: "session", mcp: { url: "https://composio.test/mcp" }, })), }; } // Build a Response-like object that mirrors what `fetch` returns for an MCP // Streamable HTTP JSON response: `text()`, `json()`, and a `content-type` // header. Production now reads the body via `text()` + content-type so it can // also decode SSE-framed responses, so test doubles must supply both. function mcpHttpResponse( payload: unknown, opts: { contentType?: string; body?: string } = {}, ): Response { const contentType = opts.contentType ?? "application/json"; const body = opts.body ?? JSON.stringify(payload); return { ok: true, status: 200, headers: { get: (name: string) => name.toLowerCase() === "content-type" ? contentType : null, }, text: async () => body, json: async () => payload, } as unknown as Response; } // Build an SSE-framed (`event: message\ndata: {…}`) MCP Streamable HTTP // response, the shape a spec-compliant server returns once the request carries // the `Accept: application/json, text/event-stream` header. function mcpSseResponse(payload: unknown): Response { return mcpHttpResponse(payload, { contentType: "text/event-stream", body: `event: message\ndata: ${JSON.stringify(payload)}\n\n`, }); } function mockToolsList(tools: unknown[]) { return vi .spyOn(globalThis, "fetch") .mockResolvedValue( mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools }, }), ); } const PUBLIC_MCP_FIXTURE_URL = "https://8.8.8.8/api/mcp"; async function withGalleryServerUrl( slug: string, serverUrl: string, operation: () => Promise, methodKey?: string, ): Promise { const definition = getConnectableAppDefinition(slug); const methods = definition?.methods ?? []; const method = methodKey ? methods.find((candidate) => candidate.key === methodKey) : definition ? getAvailableConnectionMethod(definition, null) : undefined; if (!method?.defaults) throw new Error(`Missing gallery method defaults for ${slug}`); const originalServerUrl = method.defaults.serverUrl; method.defaults.serverUrl = serverUrl; try { return await operation(); } finally { method.defaults.serverUrl = originalServerUrl; } } function createRouteApp( db: ReturnType, actor?: Express.Request["actor"], toolGateway?: ToolGatewayService, deployment?: { deploymentMode?: "local_trusted" | "authenticated"; deploymentExposure?: "private" | "public"; paperclipCloudConnector?: PaperclipCloudConnector | null; }, useProtocolFixtureTransport = true, ) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor ?? { type: "board", userId: "board-user", userName: "Board User", userEmail: null, isInstanceAdmin: true, source: "local_implicit", }; next(); }); app.use( "/api", toolAccessRoutes(db, { toolGateway, ...(useProtocolFixtureTransport ? { remoteHttpEndpointLookup: async () => [ { address: "8.8.8.8", family: 4 as const }, ], remoteHttpRequest: async (url: string, init: RequestInit) => fetch(url, init), } : {}), ...deployment, }), ); app.use(errorHandler); return app; } function boardSessionActor( companyId: string, membershipRole: "owner" | "admin" | "operator" | "member" | "viewer", userId = `${membershipRole}-${randomUUID()}`, sessionId = `session-${randomUUID()}`, ): Express.Request["actor"] { return { type: "board", userId, sessionId, userName: `${membershipRole} user`, userEmail: null, isInstanceAdmin: false, source: "session", companyIds: [companyId], memberships: [{ companyId, membershipRole, status: "active" }], }; } async function grantBoardUser( db: ReturnType, companyId: string, userId: string, permissionKeys: string[], membershipRole: "owner" | "admin" | "operator" | "member" | "viewer" = "operator", ) { await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole, }); if (permissionKeys.length > 0) { await db.insert(principalPermissionGrants).values( permissionKeys.map((permissionKey) => ({ companyId, principalType: "user", principalId: userId, permissionKey, scope: null, grantedByUserId: "owner", })), ); } } async function createAgent( db: ReturnType, companyId: string, status = "active", ) { return db .insert(agents) .values({ companyId, name: `Test Agent ${randomUUID()}`, role: "engineer", status, adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning() .then((rows) => rows[0]!); } async function createIssueAndRun( db: ReturnType, companyId: string, agentId: string, ) { await db .insert(companyMemberships) .values({ companyId, principalType: "user", principalId: "user-for-run", status: "active", membershipRole: "member", }) .onConflictDoNothing(); const [issue] = await db .insert(issues) .values({ companyId, title: `Broker issue ${randomUUID()}`, status: "in_progress", assigneeAgentId: agentId, }) .returning(); const [run] = await db .insert(heartbeatRuns) .values({ companyId, agentId, invocationSource: "assignment", status: "running", contextSnapshot: { issueId: issue!.id, responsibleUserId: "user-for-run", }, }) .returning(); return { issue: issue!, run: run! }; } function agentJwtActor( companyId: string, agentId: string, runId: string, ): Express.Request["actor"] { return { type: "agent", companyId, agentId, runId, source: "agent_jwt", }; } async function allowConnectionForAgent( db: ReturnType, companyId: string, agentId: string, connectionId: string, input: { brokerMint?: boolean } = {}, ) { await db.insert(toolConnectionInstalls).values({ companyId, connectionId, targetType: "agent", targetId: agentId, }); const [profile] = await db .insert(toolProfiles) .values({ companyId, profileKey: `broker-${randomUUID()}`, name: `Broker profile ${randomUUID()}`, defaultAction: "deny", }) .returning(); await db.insert(toolProfileBindings).values({ companyId, profileId: profile!.id, targetType: "agent", targetId: agentId, }); await db.insert(toolProfileEntries).values({ companyId, profileId: profile!.id, selectorType: "connection", effect: "include", connectionId, }); if (input.brokerMint ?? true) { await db.insert(toolProfileEntries).values({ companyId, profileId: profile!.id, selectorType: "tool_name", effect: "include", toolName: "connection_token.mint", }); } return profile!; } async function createBrokerConnection( db: ReturnType, companyId: string, input: { path?: "exchange" | "static"; parentScopes?: string[]; defaultScopes?: string[]; rateLimitPerHour?: number; healthStatus?: | "unknown" | "healthy" | "degraded" | "failed" | "unchecked" | "ok" | "error" | "missing_secret"; tokenUrl?: string; protocol?: "pages" | "generic" | "rfc8693"; } = {}, ) { const secret = await secretService(db).create(companyId, { provider: "local_encrypted", name: `Broker parent ${randomUUID()}`, key: `broker.parent.${randomUUID()}`, value: "parent-deploy-token", }); const [application] = await db .insert(toolApplications) .values({ companyId, applicationKey: "paperclip-pages", name: `Paperclip Pages ${randomUUID()}`, type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId, applicationId: application!.id, name: `Pages connection ${randomUUID()}`, uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, healthStatus: input.healthStatus ?? "ok", config: { service: "pages", namespaceAllowlist: ["dotta"], tokenBroker: { enabled: true, path: input.path ?? "exchange", tokenUrl: input.tokenUrl ?? "https://93.184.216.34/v1/tokens/exchange", ...(input.protocol ? { protocol: input.protocol } : {}), parentCredentialConfigPath: "credentials.deploy_token", parentScopes: input.parentScopes ?? ["pages:publish:ns/dotta"], defaultScopes: input.defaultScopes ?? [], ...(input.rateLimitPerHour !== undefined ? { rateLimitPerHour: input.rateLimitPerHour } : {}), }, }, transportConfig: {}, credentialSecretRefs: [ { secretId: secret.id, versionSelector: "latest", configPath: "credentials.deploy_token", required: true, label: "Pages deploy token", }, ], }) .returning(); await db.insert(companySecretBindings).values({ companyId, secretId: secret.id, targetType: "tool_connection", targetId: connection!.id, configPath: "credentials.deploy_token", }); return { application: application!, connection: connection!, secret }; } async function createOAuthConnection( db: ReturnType, companyId: string, input: { tokenBroker?: Record } = {}, ) { const accessSecret = await secretService(db).create(companyId, { provider: "local_encrypted", name: `OAuth access ${randomUUID()}`, key: `oauth.access.${randomUUID()}`, value: "stored-upstream-oauth-access-token", }); const [application] = await db .insert(toolApplications) .values({ companyId, applicationKey: `oauth-fixture-${randomUUID()}`, name: `OAuth fixture ${randomUUID()}`, type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId, applicationId: application!.id, name: `OAuth connection ${randomUUID()}`, uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, healthStatus: "ok", config: { url: "https://oauth-app.example.test/mcp", oauth: { provider: "slack", tokenUrl: "https://oauth-app.example.test/oauth/token", scopes: ["channels:write"], expiresAt: new Date(Date.now() + 3_600_000).toISOString(), }, ...(input.tokenBroker ? { tokenBroker: input.tokenBroker } : {}), }, transportConfig: { url: "https://oauth-app.example.test/mcp" }, credentialSecretRefs: [ { secretId: accessSecret.id, versionSelector: "latest", configPath: "oauth.access_token", required: true, label: "OAuth access token", }, ], }) .returning(); await db.insert(companySecretBindings).values({ companyId, secretId: accessSecret.id, targetType: "tool_connection", targetId: connection!.id, configPath: "oauth.access_token", }); return { application: application!, connection: connection!, accessSecret }; } async function createRemoteToolFixture( db: ReturnType, companyId: string, input: { riskLevel?: "read" | "write" | "destructive"; quarantined?: boolean; } = {}, ) { const [application] = await db .insert(toolApplications) .values({ companyId, applicationKey: `fixture-${randomUUID()}`, name: `Fixture App ${randomUUID()}`, type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId, applicationId: application!.id, name: `Fixture Connection ${randomUUID()}`, uid: `fixture/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example.test/mcp" }, transportConfig: { url: "https://fixture.example.test/mcp" }, healthStatus: "ok", credentialPolicy: "shared", }) .returning(); await db.insert(connectionGrants).values({ companyId, connectionId: connection!.id, kind: "organization", credentialSecretRefs: [], status: "active", isDefault: true, }); const riskLevel = input.riskLevel ?? "write"; const [catalogEntry] = await db .insert(toolCatalogEntries) .values({ companyId, applicationId: application!.id, connectionId: connection!.id, entryKind: "tool", name: `send_email-${randomUUID()}`, toolName: "send_email", title: "Send email", description: "Send a fixture email.", inputSchema: { type: "object", properties: { to: { type: "string" }, body: { type: "string" } }, required: ["to"], additionalProperties: true, }, annotations: { readOnlyHint: riskLevel === "read" }, riskLevel, isReadOnly: riskLevel === "read", isWrite: riskLevel === "write", isDestructive: riskLevel === "destructive", status: "active", versionHash: randomUUID(), schemaHash: randomUUID(), quarantinedAt: input.quarantined ? new Date() : null, quarantineReason: input.quarantined ? "pending_review" : null, }) .returning(); return { application: application!, connection: connection!, catalogEntry: catalogEntry!, }; } describeEmbeddedPostgres("tool access service", () => { let db!: ReturnType; let tempDb: Awaited< ReturnType > | null = null; beforeAll(async () => { const externalDatabaseUrl = process.env.PAPERCLIP_TOOL_ACCESS_TEST_DATABASE_URL?.trim(); if (externalDatabaseUrl) { db = createDb(externalDatabaseUrl); return; } tempDb = await startEmbeddedPostgresTestDatabase( "paperclip-tool-access-service-", ); db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); await db.delete(toolOauthStates); await db.delete(connectionTokenIssuances); await db.delete(secretAccessEvents); await db.delete(companySecretBindings); await db.delete(companySecrets); await db.delete(activityLog); await db.delete(toolCallEvents); await db.delete(toolActionRequests); await db.delete(toolInvocations); await db.delete(toolAccessAuditEvents); await db.delete(issueThreadInteractions); await db.delete(toolRuntimeMetricCounters); await db.delete(toolRuntimeSlots); await db.delete(toolStdioCommandTemplates); await db.delete(toolConnectionInstalls); await db.delete(toolMcpGateways); await db.delete(toolProfileBindings); await db.delete(toolProfileEntries); await db.delete(toolProfiles); await db.delete(toolCatalogEntries); await db.delete(toolConnections); await db.delete(toolApplications); await db.delete(issues); await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); await db.delete(companies); await db.delete(authUsers); }); afterAll(async () => { await tempDb?.cleanup(); }); async function waitForBlockedMembershipUpdate() { for (let attempt = 0; attempt < 80; attempt += 1) { const [waiting] = await db.execute<{ waiting: boolean }>(sql` SELECT EXISTS ( SELECT 1 FROM pg_stat_activity WHERE state = 'active' AND wait_event_type = 'Lock' AND query ILIKE '%company_memberships%' AND query ILIKE '%for update%' ) AS waiting `); if (waiting?.waiting) return true; await new Promise((resolve) => setTimeout(resolve, 25)); } return false; } it("mints generic exchange connection tokens through the agent route and stores only hashes", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url, init) => { expect(String(url)).toBe("https://93.184.216.34/v1/tokens/exchange"); expect(init?.headers).toEqual( expect.objectContaining({ authorization: "Bearer parent-deploy-token", }), ); const body = JSON.parse(String(init?.body)); expect(body).toMatchObject({ namespace: "dotta", ttlSeconds: 900, actions: ["publish"], actor: { type: "agent", id: agent.id, runId: run.id, onBehalfOf: "user:user-for-run", }, }); return { ok: true, status: 201, json: async () => ({ token: "child-pages-token", expiresAt: new Date(Date.now() + 900_000).toISOString(), scope: "pages:publish:ns/dotta", token_type: "Bearer", }), } as Response; }); const res = await request(app) .post( `/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`, ) .set("X-Paperclip-Run-Id", run.id) .send({ scope: "pages:publish:ns/dotta", requestedTtlSeconds: 5000 }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ status: "minted", connectionId: connection.id, connection: { id: connection.id, uid: connection.uid }, grantId: expect.any(String), path: "exchange", token: "child-pages-token", tokenType: "Bearer", ttlSeconds: expect.any(Number), scope: ["pages:publish:ns/dotta"], attribution: { agentId: agent.id, runId: run.id, issueId: expect.any(String), responsibleUserId: "user-for-run", }, }); expect(res.body.ttlSeconds).toBeLessThanOrEqual(900); expect(fetchMock).toHaveBeenCalledTimes(1); await db .update(companyMemberships) .set({ membershipRole: "viewer" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "user-for-run"), ), ); const revoked = await request(app) .post( `/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`, ) .set("X-Paperclip-Run-Id", run.id) .send({ scope: "pages:publish:ns/dotta" }); expect(revoked.status).toBe(403); expect(revoked.body.error).toContain("no longer authorized"); expect(fetchMock).toHaveBeenCalledTimes(1); const issuances = await db.select().from(connectionTokenIssuances); expect(issuances).toHaveLength(1); expect(issuances[0]).toMatchObject({ companyId: company.id, connectionId: connection.id, agentId: agent.id, runId: run.id, path: "exchange", outcome: "success", tokenHash: createHash("sha256").update("child-pages-token").digest("hex"), }); expect(JSON.stringify(issuances)).not.toContain("child-pages-token"); expect(JSON.stringify(issuances)).not.toContain("parent-deploy-token"); const secretEvents = await db .select() .from(secretAccessEvents) .where(eq(secretAccessEvents.consumerId, connection.id)); expect(secretEvents).toEqual( expect.arrayContaining([ expect.objectContaining({ actorType: "agent", actorId: agent.id, configPath: "credentials.deploy_token", heartbeatRunId: run.id, outcome: "success", }), ]), ); }); it("serializes exchange-token minting behind responsible-user membership revocation", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const mintDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); const revocationDb = createDb(tempDb!.connectionString, { maxConnections: 1, }); const service = createTestToolAccessService(mintDb); const fetchMock = vi .spyOn(globalThis, "fetch") .mockRejectedValue( new Error("membership revocation must win before token exchange"), ); let releaseRevocation!: () => void; const revocationMayCommit = new Promise((resolve) => { releaseRevocation = resolve; }); let membershipLocked!: () => void; const membershipIsLocked = new Promise((resolve) => { membershipLocked = resolve; }); let revocation: Promise | null = null; try { revocation = revocationDb.transaction(async (tx) => { await tx .select({ id: companyMemberships.id }) .from(companyMemberships) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, "user-for-run"), ), ) .for("update"); membershipLocked(); await revocationMayCommit; await tx .update(companyMemberships) .set({ membershipRole: "viewer", updatedAt: new Date() }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, "user-for-run"), ), ); }); await membershipIsLocked; const mint = service .mintConnectionTokenForAgent({ connectionId: connection.id, companyId: company.id, agentId: agent.id, runId: run.id, body: { scope: "pages:publish:ns/dotta" }, }) .then( (value) => ({ value, error: null }), (error: unknown) => ({ value: null, error }), ); expect(await waitForBlockedMembershipUpdate()).toBe(true); expect(fetchMock).not.toHaveBeenCalled(); releaseRevocation(); await revocation; const outcome = await mint; expect(outcome.value).toBeNull(); expect(outcome.error).toMatchObject({ status: 403, message: expect.stringContaining("no longer authorized"), details: { code: "responsible_user_unauthorized" }, }); expect(fetchMock).not.toHaveBeenCalled(); await expect( db .select({ outcome: connectionTokenIssuances.outcome, tokenHash: connectionTokenIssuances.tokenHash, errorCode: connectionTokenIssuances.errorCode, }) .from(connectionTokenIssuances) .where(eq(connectionTokenIssuances.connectionId, connection.id)), ).resolves.toEqual([ { outcome: "failure", tokenHash: null, errorCode: "responsible_user_unauthorized", }, ]); } finally { releaseRevocation(); await revocation?.catch(() => undefined); await mintDb.$client.end({ timeout: 0 }).catch(() => undefined); await revocationDb.$client.end({ timeout: 0 }).catch(() => undefined); } }, 15_000); it("denies token minting with an actionable error when the requesting agent has no install", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); await db .delete(toolConnectionInstalls) .where(eq(toolConnectionInstalls.connectionId, connection.id)); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi.spyOn(globalThis, "fetch"); const res = await request(app) .post( `/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`, ) .set("X-Paperclip-Run-Id", run.id) .send({ scope: "pages:publish:ns/dotta" }); expect(res.status).toBe(403); expect(res.body).toMatchObject({ code: "installation_required", connection: { id: connection.id, name: connection.name }, remediation: { action: "install_connection", targetType: "agent", targetId: agent.id, }, }); expect(fetchMock).not.toHaveBeenCalled(); const [audit] = await db .select() .from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.reasonCode, "installation_required")); expect(audit).toMatchObject({ actorType: "agent", actorId: agent.id, outcome: "failure", }); }); it("accepts a company-wide install when minting a token", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id, { path: "static", }); await allowConnectionForAgent(db, company.id, agent.id, connection.id); await db .update(toolConnectionInstalls) .set({ targetType: "company", targetId: company.id }) .where(eq(toolConnectionInstalls.connectionId, connection.id)); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const res = await request(app) .post( `/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`, ) .set("X-Paperclip-Run-Id", run.id) .send({ scope: "pages:publish:ns/dotta" }); expect(res.status).toBe(409); expect(res.body).toMatchObject({ status: "use_env_lease", connectionId: connection.id, }); }); it.each([ ["generic", undefined], ["RFC 8693", "rfc8693" as const], ])( "blocks a link-local %s token broker before credentials reach fetch", async (_label, protocol) => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id, { tokenUrl: "http://169.254.169.254/latest/meta-data", ...(protocol ? { protocol } : {}), }); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp( db, agentJwtActor(company.id, agent.id, run.id), ); const fetchMock = vi .spyOn(globalThis, "fetch") .mockRejectedValue( new Error("the parent credential must never reach the broker"), ); const res = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "pages:publish:ns/dotta" }); expect(res.status).toBe(400); expect(res.body).toMatchObject({ code: "remote_http_private_endpoint" }); expect(fetchMock).not.toHaveBeenCalled(); const [issuance] = await db.select().from(connectionTokenIssuances); expect(issuance).toMatchObject({ connectionId: connection.id, outcome: "failure", errorCode: "remote_http_private_endpoint", tokenHash: null, }); }, ); it("allows an explicitly allowlisted internal token broker through the guarded fetch", async () => { vi.stubEnv( "PAPERCLIP_TOKEN_BROKER_ALLOWED_HOSTS", "broker.example, 127.0.0.1", ); const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id, { tokenUrl: "http://127.0.0.1:8787/v1/tokens/exchange", }); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url, init) => { expect(String(url)).toBe("http://127.0.0.1:8787/v1/tokens/exchange"); expect(init).toMatchObject({ method: "POST", redirect: "manual", headers: expect.objectContaining({ authorization: "Bearer parent-deploy-token", }), }); return { ok: true, status: 201, json: async () => ({ token: "allowlisted-child-token", expires_in: 600, scope: "pages:publish:ns/dotta", }), } as Response; }); const res = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "pages:publish:ns/dotta" }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ token: "allowlisted-child-token" }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("selects scoped credentials for array scopes and fails closed for unknown selectors", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id, { parentScopes: ["staging", "production"], }); const productionSecret = await secretService(db).create(company.id, { provider: "local_encrypted", name: `Production broker parent ${randomUUID()}`, key: `broker.production.${randomUUID()}`, value: "production-deploy-token", }); await db .update(toolConnections) .set({ config: { ...connection.config, tokenBroker: { ...(connection.config.tokenBroker as Record), parentCredentialConfigPath: "credentials.production_token", }, }, credentialSecretRefs: [ ...connection.credentialSecretRefs, { secretId: productionSecret.id, versionSelector: "latest", configPath: "credentials.production_token", required: true, label: "Production deploy token", keyScope: "production", }, ], updatedAt: new Date(), }) .where(eq(toolConnections.id, connection.id)); await db.insert(companySecretBindings).values({ companyId: company.id, secretId: productionSecret.id, targetType: "tool_connection", targetId: connection.id, configPath: "credentials.production_token", }); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 201, json: async () => ({ token: "unexpected-production-token", expiresAt: new Date(Date.now() + 900_000).toISOString(), scope: "staging", }), } as Response); const res = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "staging" }); expect(res.status).toBe(422); expect(res.body).toMatchObject({ code: "parent_credential_missing" }); expect(fetchMock).not.toHaveBeenCalled(); const productionSecretEvents = await db .select() .from(secretAccessEvents) .where( and( eq(secretAccessEvents.consumerId, connection.id), eq(secretAccessEvents.configPath, "credentials.production_token"), ), ); expect(productionSecretEvents).toHaveLength(0); fetchMock.mockClear(); fetchMock.mockImplementation(async (_url, init) => { expect(init?.headers).toEqual( expect.objectContaining({ authorization: "Bearer production-deploy-token", }), ); return { ok: true, status: 201, json: async () => ({ token: "production-child-token", expiresAt: new Date(Date.now() + 900_000).toISOString(), scope: "production", }), } as Response; }); const productionRes = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: ["production"] }); expect(productionRes.status).toBe(200); expect(productionRes.body).toMatchObject({ token: "production-child-token", scope: ["production"], }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("returns typed subject errors and rejects revoked grants immediately", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const denied = await request(app) .post( `/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`, ) .send({ subject: { type: "user", userId: "someone-else" } }); expect(denied.status).toBe(403); expect(denied.body).toMatchObject({ code: "subject_not_permitted", connection: { uid: connection.uid }, subject: { type: "user", userId: "someone-else" }, }); await db .update(toolConnections) .set({ credentialPolicy: "per_user" }) .where(eq(toolConnections.id, connection.id)); const missing = await request(app) .post( `/api/agents/me/connections/${encodeURIComponent(connection.uid)}/token`, ) .send({ subject: { type: "user", userId: "user-for-run" } }); expect(missing.status).toBe(409); expect(missing.body).toMatchObject({ code: "user_authorization_required", remediation: { action: "start_authorization" }, }); const [grant] = await db .insert(connectionGrants) .values({ companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId: "user-for-run", status: "revoked", isDefault: false, }) .returning(); const revoked = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({}); expect(revoked.status).toBe(409); expect(revoked.body).toMatchObject({ code: "grant_revoked", grantId: grant.id, }); }); it("allows only the personal grant owner to create named-agent delegations and audits revocation", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); await grantBoardUser(db, company.id, "alice", [], "member"); await grantBoardUser(db, company.id, "mallory", [], "member"); const { connection } = await createBrokerConnection(db, company.id); const grant = await db .insert(connectionGrants) .values({ companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId: "alice", status: "active", isDefault: false, }) .returning() .then((rows) => rows[0]!); const service = createTestToolAccessService(db); await expect( service.createConnectionGrantDelegation( connection.id, grant.id, agent.id, "mallory", ), ).rejects.toThrow( "Only the active personal grant owner can create a delegation", ); const delegation = await service.createConnectionGrantDelegation( connection.id, grant.id, agent.id, "alice", ); expect(delegation).toMatchObject({ grantId: grant.id, agentId: agent.id, createdByUserId: "alice", }); await service.revokeConnectionGrantDelegation( connection.id, grant.id, delegation.id, { actorType: "user", actorId: "manager" }, ); expect( await db .select() .from(connectionGrantDelegations) .where(eq(connectionGrantDelegations.id, delegation.id)), ).toHaveLength(0); expect( await db .select() .from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.connectionId, connection.id)), ).toEqual( expect.arrayContaining([ expect.objectContaining({ action: "connection_grant.delegated", actorId: "alice", }), expect.objectContaining({ action: "connection_grant.delegation_revoked", actorId: "manager", }), ]), ); }); it("enforces delegation owner and manager permissions through the HTTP routes", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); await grantBoardUser(db, company.id, "alice", [], "member"); await grantBoardUser(db, company.id, "mallory", [], "member"); await grantBoardUser( db, company.id, "manager", ["tools:manage_connections"], "operator", ); const { connection } = await createBrokerConnection(db, company.id); const grant = await db .insert(connectionGrants) .values({ companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId: "alice", status: "active", isDefault: false, }) .returning() .then((rows) => rows[0]!); const nonOwner = await request( createRouteApp(db, boardSessionActor(company.id, "member", "mallory")), ) .post( `/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`, ) .send({ agentId: agent.id }); expect(nonOwner.status).toBe(403); const created = await request( createRouteApp(db, boardSessionActor(company.id, "member", "alice")), ) .post( `/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`, ) .send({ agentId: agent.id }); expect(created.status).toBe(201); expect(created.body).toMatchObject({ grantId: grant.id, agentId: agent.id, }); const unrelatedRevoke = await request( createRouteApp(db, boardSessionActor(company.id, "member", "mallory")), ).delete( `/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`, ); expect(unrelatedRevoke.status).toBe(403); const managerRevoke = await request( createRouteApp(db, boardSessionActor(company.id, "operator", "manager")), ).delete( `/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`, ); expect(managerRevoke.status).toBe(200); expect( await db .select() .from(connectionGrantDelegations) .where(eq(connectionGrantDelegations.id, created.body.id)), ).toHaveLength(0); expect( await db .select() .from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.connectionId, connection.id)), ).toEqual( expect.arrayContaining([ expect.objectContaining({ action: "connection_grant.delegated", actorId: "alice", }), expect.objectContaining({ action: "connection_grant.delegation_revoked", actorId: "manager", }), ]), ); }); it("prevents an unrelated member from health-checking a per-user connection", async () => { const company = await createCompany(db); const { connection } = await createBrokerConnection(db, company.id); await db .update(toolConnections) .set({ credentialPolicy: "per_user", createdByUserId: "alice", }) .where(eq(toolConnections.id, connection.id)); await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId: "alice", status: "active", isDefault: false, }); const response = await request( createRouteApp(db, boardSessionActor(company.id, "member", "mallory")), ).post(`/api/tool-connections/${connection.id}/health-check`); expect(response.status).toBe(403); expect(response.body.error).toContain("need access to this connection"); }); it("serializes delegation creation behind membership removal so reauthorization cannot revive stale consent", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); await grantBoardUser(db, company.id, "alice", [], "member"); const { connection } = await createBrokerConnection(db, company.id); const grant = await db .insert(connectionGrants) .values({ companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId: "alice", status: "active", isDefault: false, }) .returning() .then((rows) => rows[0]!); const serviceDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); const service = createTestToolAccessService(serviceDb); const removalDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); let releaseRemoval!: () => void; const removalMayCommit = new Promise((resolve) => { releaseRemoval = resolve; }); let membershipLocked!: () => void; const membershipIsLocked = new Promise((resolve) => { membershipLocked = resolve; }); const removal = removalDb.transaction(async (tx) => { await tx.execute(sql` SELECT "id" FROM "company_memberships" WHERE "company_id" = ${company.id} AND "principal_type" = 'user' AND "principal_id" = 'alice' FOR UPDATE `); await tx.execute(sql` UPDATE "connection_grants" SET "status" = 'revoked', "revoked_at" = now(), "updated_at" = now() WHERE "id" = ${grant.id} `); await tx.execute(sql` DELETE FROM "connection_grant_delegations" WHERE "company_id" = ${company.id} AND "grant_id" = ${grant.id} `); membershipLocked(); await removalMayCommit; await tx.execute(sql` UPDATE "company_memberships" SET "status" = 'suspended', "updated_at" = now() WHERE "company_id" = ${company.id} AND "principal_type" = 'user' AND "principal_id" = 'alice' `); }); await membershipIsLocked; let creationSettled = false; const creation = service .createConnectionGrantDelegation( connection.id, grant.id, agent.id, "alice", ) .then( (value) => ({ value, error: null }), (error: unknown) => ({ value: null, error }), ) .finally(() => { creationSettled = true; }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(creationSettled).toBe(false); releaseRemoval(); await removal; const creationResult = await creation; expect(creationResult.error).toEqual( expect.objectContaining({ message: "Only an active company member can delegate their personal grant", }), ); await db .update(companyMemberships) .set({ status: "active" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "alice"), ), ); await db .update(connectionGrants) .set({ status: "active", revokedAt: null }) .where(eq(connectionGrants.id, grant.id)); expect( await db .select() .from(connectionGrantDelegations) .where(eq(connectionGrantDelegations.grantId, grant.id)), ).toHaveLength(0); }); it("uses the responsible user's personal grant for autonomous token minting", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { issue, run } = await createIssueAndRun(db, company.id, agent.id); await db .update(heartbeatRuns) .set({ invocationSource: "automation" }) .where(eq(heartbeatRuns.id, run.id)); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); await db .update(toolConnections) .set({ credentialPolicy: "per_user" }) .where(eq(toolConnections.id, connection.id)); const grant = await db .insert(connectionGrants) .values({ companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId: "user-for-run", credentialSecretRefs: connection.credentialSecretRefs, status: "active", isDefault: false, }) .returning() .then((rows) => rows[0]!); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 201, json: async () => ({ token: "responsible-user-child-token", expires_in: 600, scope: "pages:publish:ns/dotta", }), } as Response); const allowed = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "pages:publish:ns/dotta" }); expect(allowed.status).toBe(200); expect(allowed.body).toMatchObject({ token: "responsible-user-child-token", }); expect(fetchMock).toHaveBeenCalledTimes(1); expect( await db .select() .from(issueThreadInteractions) .where(eq(issueThreadInteractions.issueId, issue.id)), ).toEqual([]); await db .update(companyMemberships) .set({ status: "suspended" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "user-for-run"), ), ); const inactiveOwner = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({}); expect(inactiveOwner.status).toBe(403); expect(inactiveOwner.body.error).toContain("no longer authorized"); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("enforces organization grant audiences at token mint time", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const [grant] = await db .insert(connectionGrants) .values({ companyId: company.id, connectionId: connection.id, kind: "organization", credentialSecretRefs: connection.credentialSecretRefs, status: "active", isDefault: true, }) .returning(); await db.insert(connectionGrantMembers).values({ companyId: company.id, grantId: grant!.id, subjectType: "user", subjectId: "user-for-run", }); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 201, json: async () => ({ token: "audience-token", expires_in: 600 }), } as Response); const allowed = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({}); expect(allowed.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(1); await db .update(companyMemberships) .set({ status: "suspended" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "user-for-run"), ), ); const inactiveAudienceMember = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({}); expect(inactiveAudienceMember.status).toBe(403); expect(inactiveAudienceMember.body.error).toContain("no longer authorized"); expect(fetchMock).toHaveBeenCalledTimes(1); await db .update(companyMemberships) .set({ status: "active" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "user-for-run"), ), ); await db .delete(connectionGrantMembers) .where(eq(connectionGrantMembers.grantId, grant!.id)); await db.insert(connectionGrantMembers).values({ companyId: company.id, grantId: grant!.id, subjectType: "user", subjectId: "sales-user", }); const denied = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({}); expect(denied.status).toBe(403); expect(denied.body).toMatchObject({ code: "grant_audience_denied", grantId: grant!.id, }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it.each(["suspend", "archive", "remove"] as const)( "keeps an organization audience fail-closed when empty replacement races with member %s", async (cleanupKind) => { const company = await createCompany(db); await grantBoardUser(db, company.id, "owner", [], "owner"); await grantBoardUser(db, company.id, "previous-user", [], "member"); await grantBoardUser(db, company.id, "departing-user", [], "member"); const departingMembership = await db .select() .from(companyMemberships) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, "departing-user"), ), ) .then((rows) => rows[0]!); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const grant = await db .insert(connectionGrants) .values({ companyId: company.id, connectionId: connection.id, kind: "organization", credentialSecretRefs: connection.credentialSecretRefs, status: "active", isDefault: true, }) .returning() .then((rows) => rows[0]!); await db.insert(connectionGrantMembers).values({ companyId: company.id, grantId: grant.id, subjectType: "user", subjectId: "previous-user", }); const firstReplacementDb = createDb(tempDb!.connectionString, { maxConnections: 1, }); const replacementDb = createDb(tempDb!.connectionString, { maxConnections: 1, }); const cleanupDb = createDb(tempDb!.connectionString, { maxConnections: 1, }); const replacementService = createTestToolAccessService(replacementDb); let releaseFirstReplacement!: () => void; const firstReplacementMayCommit = new Promise((resolve) => { releaseFirstReplacement = resolve; }); let firstReplacementStaged!: () => void; const firstReplacementIsStaged = new Promise((resolve) => { firstReplacementStaged = resolve; }); let releaseCleanup!: () => void; const cleanupMayFinish = new Promise((resolve) => { releaseCleanup = resolve; }); let membershipLocked!: () => void; const membershipIsLocked = new Promise((resolve) => { membershipLocked = resolve; }); const firstReplacement = firstReplacementDb.transaction(async (tx) => { const service = createTestToolAccessService( tx as unknown as ReturnType, ); await service.replaceConnectionGrantMembers(connection.id, grant.id, [ "departing-user", ]); firstReplacementStaged(); await firstReplacementMayCommit; }); await firstReplacementIsStaged; const cleanup = cleanupDb.transaction(async (tx) => { await tx .select({ id: companyMemberships.id }) .from(companyMemberships) .where(eq(companyMemberships.id, departingMembership.id)) .for("update"); membershipLocked(); await cleanupMayFinish; const access = accessService( tx as unknown as ReturnType, ); if (cleanupKind === "suspend") { await access.updateMemberAndPermissions( company.id, departingMembership.id, { status: "suspended", grants: [] }, "owner", ); } else if (cleanupKind === "archive") { await access.archiveMember(company.id, departingMembership.id); } else { await access.setUserCompanyAccess("departing-user", []); } }); // The first replacement still owns the departing member lock, so this // gives cleanup time to queue for that lock before the empty replacement // queues for the grant lock. await new Promise((resolve) => setTimeout(resolve, 50)); let replacementSettled = false; const replacement = replacementService .replaceConnectionGrantMembers(connection.id, grant.id, []) .then( (value) => ({ value, error: null }), (error: unknown) => ({ value: null, error }), ) .finally(() => { replacementSettled = true; }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(replacementSettled).toBe(false); releaseFirstReplacement(); await firstReplacement; await membershipIsLocked; expect(replacementSettled).toBe(false); releaseCleanup(); await cleanup; const replacementResult = await replacement; expect(replacementResult.value).toBeNull(); expect(replacementResult.error).toEqual( expect.objectContaining({ message: "Replace inactive audience members before widening access to the whole company", status: 409, details: { code: "audience_widening_blocked", inactiveUserIds: ["departing-user"], }, }), ); expect( await db .select() .from(connectionGrantMembers) .where(eq(connectionGrantMembers.grantId, grant.id)), ).toEqual([ expect.objectContaining({ subjectType: "user", subjectId: "departing-user", }), ]); const fetchMock = vi .spyOn(globalThis, "fetch") .mockResolvedValue( mcpHttpResponse({ token: "unexpected-company-wide-token" }), ); const denied = await request( createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)), ) .post(`/api/agents/me/connections/${connection.id}/token`) .send({}); expect(denied.status).toBe(403); expect(denied.body).toMatchObject({ code: "grant_audience_denied", grantId: grant.id, }); expect(fetchMock).not.toHaveBeenCalled(); }, ); it("returns daily connection usage buckets", async () => { const company = await createCompany(db); const { connection } = await createBrokerConnection(db, company.id); const service = createTestToolAccessService(db); await db.insert(connectionTokenIssuances).values({ companyId: company.id, applicationId: connection.applicationId, connectionId: connection.id, agentId: (await createAgent(db, company.id)).id, path: "exchange", requestedScope: [], issuedScope: [], outcome: "success", }); await db.insert(toolInvocations).values({ companyId: company.id, connectionId: connection.id, toolName: "fixture", riskLevel: "write", }); const usage = await service.getConnectionUsage( connection.uid, "7d", company.id, ); expect(usage.connection).toEqual({ id: connection.id, uid: connection.uid, }); expect(usage.buckets.at(-1)).toMatchObject({ issuances: { total: 1, byOutcome: { success: 1 }, byPath: { exchange: 1 }, }, invocations: { total: 1, byRiskLevel: { write: 1 } }, }); }); it("rejects connection token minting after the heartbeat run completes", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); await db .update(heartbeatRuns) .set({ status: "succeeded" }) .where(eq(heartbeatRuns.id, run.id)); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi .spyOn(globalThis, "fetch") .mockRejectedValue(new Error("inactive runs must not call upstream")); const res = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .set("X-Paperclip-Run-Id", run.id) .send({ scope: "pages:publish:ns/dotta" }); expect(res.status).toBe(403); expect(res.body.error).toBe("Agent run is not active"); expect(fetchMock).not.toHaveBeenCalled(); }); it("denies broker minting when the agent only has a generic connection profile grant", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id, { brokerMint: false, }); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi .spyOn(globalThis, "fetch") .mockRejectedValue(new Error("broker mint should not call upstream")); const res = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "pages:publish:ns/dotta" }); expect(res.status).toBe(403); expect(res.body).toMatchObject({ code: "broker_mint_not_granted" }); expect(fetchMock).not.toHaveBeenCalled(); const [issuance] = await db.select().from(connectionTokenIssuances); expect(issuance).toMatchObject({ connectionId: connection.id, path: "exchange", outcome: "denied", errorCode: "broker_mint_not_granted", tokenHash: null, }); }); it("does not infer oauth_access for OAuth-backed connections without broker opt-in", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createOAuthConnection(db, company.id); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi .spyOn(globalThis, "fetch") .mockRejectedValue( new Error("oauth broker refusal should not call upstream"), ); const res = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "channels:write" }); expect(res.status).toBe(403); expect(res.body).toMatchObject({ code: "broker_not_enabled" }); expect(JSON.stringify(res.body)).not.toContain( "stored-upstream-oauth-access-token", ); expect(fetchMock).not.toHaveBeenCalled(); const [issuance] = await db.select().from(connectionTokenIssuances); expect(issuance).toMatchObject({ connectionId: connection.id, path: "static", outcome: "denied", errorCode: "broker_not_enabled", tokenHash: null, }); expect(issuance?.path).not.toBe("oauth_access"); const secretEvents = await db .select() .from(secretAccessEvents) .where(eq(secretAccessEvents.consumerId, connection.id)); expect(secretEvents).toHaveLength(0); }); it("refuses explicit oauth_access broker paths without projecting stored OAuth bearers", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createOAuthConnection(db, company.id, { tokenBroker: { enabled: true, path: "oauth_access", parentScopes: ["channels:write"], defaultScopes: ["channels:write"], }, }); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi .spyOn(globalThis, "fetch") .mockRejectedValue( new Error("oauth_access refusal should not call upstream"), ); const res = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "channels:write" }); expect(res.status).toBe(422); expect(res.body).toMatchObject({ code: "oauth_access_projection_disabled", }); expect(JSON.stringify(res.body)).not.toContain( "stored-upstream-oauth-access-token", ); expect(fetchMock).not.toHaveBeenCalled(); const [issuance] = await db.select().from(connectionTokenIssuances); expect(issuance).toMatchObject({ connectionId: connection.id, path: "oauth_access", outcome: "denied", errorCode: "oauth_access_projection_disabled", tokenHash: null, }); const secretEvents = await db .select() .from(secretAccessEvents) .where(eq(secretAccessEvents.consumerId, connection.id)); expect(secretEvents).toHaveLength(0); }); it("returns a typed use_env_lease refusal for static credential delivery", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id, { path: "static", }); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const res = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "pages:publish:ns/dotta" }); expect(res.status).toBe(409); expect(res.body).toMatchObject({ status: "use_env_lease", code: "use_env_lease", path: "static", connectionId: connection.id, }); const [issuance] = await db.select().from(connectionTokenIssuances); expect(issuance).toMatchObject({ connectionId: connection.id, path: "static", outcome: "use_env_lease", errorCode: "use_env_lease", tokenHash: null, }); }); it("denies token scopes outside the parent scope before minting", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id, { parentScopes: ["pages:publish:ns/dotta"], }); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); const fetchMock = vi.spyOn(globalThis, "fetch"); const res = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "pages:publish:ns/other" }); expect(res.status).toBe(403); expect(res.body).toMatchObject({ code: "scope_exceeds_parent" }); expect(fetchMock).not.toHaveBeenCalled(); const [issuance] = await db.select().from(connectionTokenIssuances); expect(issuance).toMatchObject({ outcome: "denied", errorCode: "scope_exceeds_parent", tokenHash: null, }); }); it("rate limits connection token minting per agent and connection", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); const { connection } = await createBrokerConnection(db, company.id, { rateLimitPerHour: 1, }); await allowConnectionForAgent(db, company.id, agent.id, connection.id); const app = createRouteApp(db, agentJwtActor(company.id, agent.id, run.id)); vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 201, json: async () => ({ token: `child-${randomUUID()}`, expires_in: 600, scope: "pages:publish:ns/dotta", }), } as Response); await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "pages:publish:ns/dotta" }) .expect(200); const limited = await request(app) .post(`/api/agents/me/connections/${connection.id}/token`) .send({ scope: "pages:publish:ns/dotta" }); expect(limited.status).toBe(429); expect(limited.body).toMatchObject({ code: "rate_limited" }); const issuances = await db .select() .from(connectionTokenIssuances) .where(eq(connectionTokenIssuances.connectionId, connection.id)); expect(issuances.map((row) => row.outcome).sort()).toEqual([ "rate_limited", "success", ]); }); it("quarantines new or changed catalog entries during active opt-in catalog refresh", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ { name: "search_notes", description: "Search notes.", inputSchema: { type: "object", properties: { q: { type: "string" } } }, annotations: { readOnlyHint: true }, }, { name: "send_email", description: "Send an email.", inputSchema: { type: "object", properties: { to: { type: "string" } } }, annotations: { readOnlyHint: false }, }, ]); const connection = await service.createConnection(company.id, { name: "Remote fixture", transport: "mcp_remote", config: { url: "https://fixture.example/mcp", quarantineNewEntries: true, }, enabled: true, status: "active", }); const firstRefresh = await service.refreshCatalog(connection.id, { actorType: "user", actorId: "board", }); expect(fetchMock).toHaveBeenCalledWith( "https://fixture.example/mcp", expect.objectContaining({ method: "POST" }), ); expect(firstRefresh.discoveredCount).toBe(2); expect(firstRefresh.quarantinedCount).toBe(2); expect(firstRefresh.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "search_notes", status: "quarantined", riskLevel: "read", }), expect.objectContaining({ toolName: "send_email", status: "quarantined", riskLevel: "write", quarantineReason: "pending_review", }), ]), ); await db .update(toolCatalogEntries) .set({ status: "active", reviewedAt: new Date(), quarantineReason: null, quarantinedAt: null, }) .where(eq(toolCatalogEntries.toolName, "send_email")); fetchMock.mockResolvedValueOnce( mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "send_email", description: "Send an email with attachments.", inputSchema: { type: "object", properties: { to: { type: "string" }, attachment: { type: "string" }, }, }, annotations: { readOnlyHint: false }, }, ], }, }), ); const secondRefresh = await service.refreshCatalog(connection.id); expect(secondRefresh.quarantinedCount).toBe(1); expect(secondRefresh.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "send_email", status: "quarantined", quarantineReason: "pending_review", }), ]), ); }); it("keeps tools outside a Google Workspace capability profile disabled", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "list_labels", description: "Lists Gmail labels.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, { name: "create_label", description: "Creates a Gmail label.", inputSchema: { type: "object", properties: { name: { type: "string" } }, }, annotations: { readOnlyHint: false }, }, ]); const connected = await service.connectGalleryApp( company.id, { galleryKey: "gmail", connectionMethodKey: "customer-read-oauth", name: "Personal read-only Gmail", grantKind: "user", oauthClient: { clientId: "google-client", clientSecret: "google-secret", }, }, { actorType: "user", actorId: "board" }, ); const refresh = await service.refreshCatalog( connected.connectionId, { actorType: "user", actorId: "board" }, { enableAllByDefault: true, credentialHeaders: { Authorization: "Bearer google-access" }, }, ); const readEntry = refresh.catalog.find( (entry) => entry.toolName === "list_labels", )!; const blockedEntry = refresh.catalog.find( (entry) => entry.toolName === "create_label", )!; expect(readEntry.status).toBe("active"); expect(blockedEntry.status).toBe("disabled"); await expect( service.finishGalleryAppConnection( company.id, connected.connectionId, { enabledCatalogEntryIds: [readEntry.id, blockedEntry.id], askFirstCatalogEntryIds: [], access: "all_agents", }, { actorType: "user", actorId: "board" }, ), ).rejects.toMatchObject({ status: 400, message: "Disabled actions cannot be enabled", }); await service.finishGalleryAppConnection( company.id, connected.connectionId, { enabledCatalogEntryIds: [readEntry.id], askFirstCatalogEntryIds: [], access: "all_agents", }, { actorType: "user", actorId: "board" }, ); const [stillBlocked] = await db .select() .from(toolCatalogEntries) .where(eq(toolCatalogEntries.id, blockedEntry.id)); expect(stillBlocked!.status).toBe("disabled"); }); it("sends the MCP Streamable HTTP Accept header and decodes an SSE catalog response", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); // Emulate a spec-compliant Streamable HTTP server: 406 unless the request // advertises `Accept: application/json, text/event-stream`, and an // SSE-framed body in response. Regression guard for PAP-11096. const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (_url, init) => { const headers = (init?.headers ?? {}) as Record; const accept = headers.accept ?? headers.Accept ?? ""; if ( !accept.includes("application/json") || !accept.includes("text/event-stream") ) { return { ok: false, status: 406, headers: { get: () => null }, text: async () => JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Not Acceptable: Client must accept both application/json and text/event-stream", }, id: null, }), } as unknown as Response; } return mcpSseResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "kv_get", description: "Read a value.", annotations: { readOnlyHint: true }, }, ], }, }); }); const connection = await service.createConnection(company.id, { name: "Streamable HTTP fixture", transport: "mcp_remote", config: { url: "http://127.0.0.1:8848/mcp" }, enabled: true, status: "active", }); const refresh = await service.refreshCatalog(connection.id, { actorType: "user", actorId: "board", }); expect(refresh.discoveredCount).toBe(1); expect(refresh.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "kv_get", riskLevel: "read" }), ]), ); expect(fetchMock).toHaveBeenCalledWith( "http://127.0.0.1:8848/mcp", expect.objectContaining({ method: "POST", headers: expect.objectContaining({ accept: "application/json, text/event-stream", }), }), ); // The same probe backs the periodic health sweep, so it must also pass. const health = await service.checkHealth(connection.id); expect(health.connection.healthStatus).toBe("ok"); }); it.each( [ { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" }, { sourceTemplateKey: "unsupported-rest-fixture", templateId: "paperclip.echo-calculator-time", }, ].flatMap((config) => (["checkHealth", "refreshCatalog"] as const).map((operation) => ({ config, operation })), ), )("rejects unsupported REST tool connections without stdio validation: %j", async ({ config, operation }) => { const company = await createCompany(db); const service = createTestToolAccessService(db); const application = await service.createApplication(company.id, { name: "REST regression fixture", type: "rest_api", }); const connection = await service.createConnection(company.id, { applicationId: application.id, name: "REST regression fixture", transport: "rest_api", config, enabled: true, status: "active", }); const fetchMock = vi.spyOn(globalThis, "fetch"); const message = "This connection has no supported tool integration. Add a supported account or MCP connection from Connectors."; await expect(service[operation](connection.id)).rejects.toMatchObject({ status: 422, message, details: { code: "tool_connection_transport_unsupported" }, }); const [saved] = await db.select().from(toolConnections) .where(eq(toolConnections.id, connection.id)); expect(saved).toMatchObject({ healthStatus: "error", healthMessage: message }); expect(fetchMock).not.toHaveBeenCalled(); expect(await service.listRuntimeSlots(company.id)).toEqual([]); expect(await db.select().from(toolCatalogEntries) .where(eq(toolCatalogEntries.connectionId, connection.id))).toEqual([]); const audit = await db.select().from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.connectionId, connection.id)); expect(audit).toEqual(expect.arrayContaining([ expect.objectContaining({ action: operation === "checkHealth" ? "tool_connection.health_check" : "tool_connection.catalog_refresh", outcome: "failure", reasonCode: "tool_connection_transport_unsupported", }), ])); // Removing a method from the catalog must not strand its saved connections. expect(await service.archiveConnection(connection.id)).toMatchObject({ connection: { status: "archived" }, }); }); it("rejects the obsolete Anthropic REST setup before storing credentials", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); await expect(service.connectGalleryApp(company.id, { galleryKey: "anthropic", connectionMethodKey: "api-key", credentialValues: { "credentials.apiKey": "rest-regression-secret" }, }, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ status: 422, message: "This app does not have an available connection method", }); expect(await db.select().from(toolConnections) .where(eq(toolConnections.companyId, company.id))).toEqual([]); expect(await db.select().from(companySecrets) .where(eq(companySecrets.companyId, company.id))).toEqual([]); }); it("registers an approved local stdio template and exposes its runtime slot", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connection = await service.createConnection(company.id, { name: "Local echo fixture", transport: "local_stdio", config: { templateId: "paperclip.echo-calculator-time" }, enabled: true, status: "active", }); const health = await service.checkHealth(connection.id); const refresh = await service.refreshCatalog(connection.id); const runtimeSlots = await service.listRuntimeSlots(company.id); expect(health.runtimeSlot).toMatchObject({ connectionId: connection.id, runtimeKind: "local_stdio", status: "stopped", commandTemplateKey: "paperclip.echo-calculator-time", }); expect(refresh.catalog.map((entry) => entry.toolName).sort()).toEqual([ "add", "echo", "fail_with_code", "now", ]); expect(runtimeSlots).toEqual([ expect.objectContaining({ connectionId: connection.id, providerRef: "template:paperclip.echo-calculator-time", healthStatus: "ok", }), ]); }); it("requires tools:admin to create, list, and disable stdio command templates", async () => { const company = await createCompany(db); const userId = `tool-admin-${randomUUID()}`; const actor: Express.Request["actor"] = { type: "board", userId, userName: "Tool Admin", userEmail: null, isInstanceAdmin: false, source: "session", companyIds: [company.id], memberships: [ { companyId: company.id, membershipRole: "operator", status: "active" }, ], }; await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, status: "active", membershipRole: "operator", }); const app = createRouteApp(db, actor); await request(app) .get(`/api/companies/${company.id}/tools/stdio-templates`) .expect(403); await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "user", principalId: userId, permissionKey: "tools:admin", scope: null, grantedByUserId: "owner", }); const created = await request(app) .post(`/api/companies/${company.id}/tools/stdio-templates`) .send({ templateId: "local.echo-admin", name: "Local echo admin", command: "node", args: ["server.js"], envKeys: ["ECHO_TOKEN"], tools: [ { name: "echo", description: "Echo a message.", annotations: { readOnlyHint: true }, }, ], }) .expect(201); expect(created.body).toMatchObject({ templateId: "local.echo-admin", status: "active", source: "admin", command: "node", args: ["server.js"], envKeys: ["ECHO_TOKEN"], tools: [expect.objectContaining({ name: "echo" })], }); const listed = await request(app) .get(`/api/companies/${company.id}/tools/stdio-templates`) .expect(200); expect(listed.body.templates).toEqual( expect.arrayContaining([ expect.objectContaining({ templateId: "paperclip.echo-calculator-time", source: "built_in", }), expect.objectContaining({ templateId: "local.echo-admin", source: "admin", status: "active", }), ]), ); const disabled = await request(app) .post( `/api/companies/${company.id}/tools/stdio-templates/local.echo-admin/disable`, ) .send({ reason: "no longer trusted" }) .expect(200); expect(disabled.body).toMatchObject({ templateId: "local.echo-admin", status: "disabled", }); }); it("launches local stdio slots only through active admin-defined templates", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); await service.createStdioCommandTemplate( company.id, { templateId: "admin.local-echo", name: "Admin local echo", command: "node", args: ["./echo-mcp.js"], envKeys: ["ADMIN_ECHO_TOKEN"], tools: [ { name: "echo", description: "Echo a message.", annotations: { readOnlyHint: true }, }, ], }, { actorType: "user", actorId: "board" }, ); const connection = await service.createConnection(company.id, { name: "Admin local echo", transport: "local_stdio", config: { templateId: "admin.local-echo" }, enabled: true, status: "active", }); const health = await service.checkHealth(connection.id); const refresh = await service.refreshCatalog(connection.id); expect(health.runtimeSlot).toMatchObject({ connectionId: connection.id, runtimeKind: "local_stdio", commandTemplateKey: "admin.local-echo", }); expect(refresh.catalog).toEqual([ expect.objectContaining({ toolName: "echo", status: "active", riskLevel: "read", }), ]); await expect( service.createConnection(company.id, { name: "Rejected command config", transport: "local_stdio", config: { command: "node", args: ["./unapproved.js"] }, enabled: true, status: "active", }), ).rejects.toThrow( "Local stdio MCP connections must use an approved templateId", ); await service.disableStdioCommandTemplate(company.id, "admin.local-echo"); await expect( service.createConnection(company.id, { name: "Disabled admin template", transport: "local_stdio", config: { templateId: "admin.local-echo" }, enabled: true, status: "active", }), ).rejects.toThrow( "Local stdio MCP connections must use an approved templateId", ); }); it.each([ [ "local_trusted", { deploymentMode: "local_trusted" as const, deploymentExposure: "private" as const, }, ], [ "authenticated/private", { deploymentMode: "authenticated" as const, deploymentExposure: "private" as const, }, ], [ "authenticated/public", { deploymentMode: "authenticated" as const, deploymentExposure: "public" as const, }, ], ])( "always blocks link-local remote HTTP endpoints in %s before fetch", async (_label, deployment) => { const company = await createCompany(db); const service = createTestToolAccessService(db, deployment); const fetchSpy = vi .spyOn(globalThis, "fetch") .mockRejectedValue(new Error("fetch should not be called")); try { await expect( service.createConnection(company.id, { name: "Metadata endpoint", transport: "mcp_remote", config: { url: "http://169.254.169.254/latest/meta-data" }, enabled: true, status: "active", }), ).rejects.toMatchObject({ status: 400, details: { code: "remote_http_private_endpoint" }, }); expect(fetchSpy).not.toHaveBeenCalled(); } finally { fetchSpy.mockRestore(); } }, ); it("creates profiles with entries, binds them to agents, and resolves effective allowed tools", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: `Profile Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: `Profile Fixture ${randomUUID()}`, type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: `Profile Connection ${randomUUID()}`, uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, transportConfig: { url: "https://fixture.example/mcp" }, healthStatus: "ok", }) .returning(); const [catalogEntry] = await db .insert(toolCatalogEntries) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "send_email", toolName: "send_email", riskLevel: "write", status: "active", versionHash: randomUUID(), schemaHash: randomUUID(), }) .returning(); const profile = await service.createProfile(company.id, { profileKey: `profile-${randomUUID()}`, name: "Email tools", defaultAction: "deny", entries: [ { selectorType: "tool_name", effect: "include", toolName: "send_email", }, ], }); const added = await service.addProfileEntry(profile.id, { selectorType: "risk_level", effect: "exclude", riskLevel: "destructive", }); await expect( service.updateProfileEntry(added.id, { effect: "include" }), ).resolves.toMatchObject({ effect: "include", riskLevel: "destructive", }); await expect(service.deleteProfileEntry(added.id)).resolves.toMatchObject({ id: added.id, }); await service.updateProfile(profile.id, { entries: [ { selectorType: "connection", effect: "include", connectionId: connection.id, }, ], }); await service.bindProfile( profile.id, { targetType: "agent", targetId: agent.id, priority: 25 }, { actorType: "user", actorId: "board" }, ); const listed = await service.listProfiles(company.id); const effective = await service.getEffectiveProfilesForAgent( company.id, agent.id, ); expect(listed).toEqual([ expect.objectContaining({ id: profile.id, entries: [ expect.objectContaining({ selectorType: "connection", connectionId: connection.id, }), ], bindings: [ expect.objectContaining({ targetType: "agent", targetId: agent.id, priority: 25, }), ], }), ]); expect(effective).toMatchObject({ agentId: agent.id, allowedToolNames: ["send_email"], allowedTools: [ expect.objectContaining({ id: catalogEntry.id, toolName: "send_email", }), ], }); await expect( service.unbindProfile(profile.id, { targetType: "agent", targetId: agent.id, }), ).resolves.toEqual({ unbound: 1 }); await expect( service.getEffectiveProfilesForAgent(company.id, agent.id), ).resolves.toMatchObject({ profiles: [], allowedToolNames: [], }); }); it.each([ [ "tokenBroker.tokenUrl", { tokenBroker: { enabled: true, tokenUrl: "http://169.254.169.254/token", }, }, ], [ "tokenBroker.exchangeTokenUrl", { tokenBroker: { enabled: true, exchangeTokenUrl: "http://169.254.169.254/token", }, }, ], ["tokenExchangeUrl", { tokenExchangeUrl: "http://169.254.169.254/token" }], [ "pagesTokenExchangeUrl", { pagesTokenExchangeUrl: "http://169.254.169.254/token" }, ], ])( "rejects a link-local %s when a remote connection is created", async (_field, brokerConfig) => { const company = await createCompany(db); const service = createTestToolAccessService(db, { deploymentMode: "authenticated", deploymentExposure: "public", }); await expect( service.createConnection(company.id, { name: `Rejected broker ${randomUUID()}`, transport: "mcp_remote", config: { url: "https://93.184.216.34/mcp", ...brokerConfig }, enabled: true, status: "active", }), ).rejects.toMatchObject({ status: 400, details: { code: "remote_http_private_endpoint" }, }); await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); }, ); it("rejects a link-local token broker when a remote connection is updated", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db, { deploymentMode: "authenticated", deploymentExposure: "public", }); const connection = await service.createConnection(company.id, { name: "Initially safe broker", transport: "mcp_remote", config: { url: "https://93.184.216.34/mcp" }, enabled: true, status: "active", }); await expect( service.updateConnection(connection.id, { config: { ...connection.config, tokenBroker: { enabled: true, tokenUrl: "http://169.254.169.254/token", }, }, }), ).rejects.toMatchObject({ status: 400, details: { code: "remote_http_private_endpoint" }, }); await expect(service.getConnection(connection.id)).resolves.toMatchObject({ config: { url: "https://93.184.216.34/mcp" }, }); }); it("implicitly allowlists the configured Pages API host for internal token brokers", async () => { vi.stubEnv("PAPERCLIP_PAGES_API_URL", "http://127.0.0.1:8787"); const company = await createCompany(db); const service = createTestToolAccessService(db, { deploymentMode: "authenticated", deploymentExposure: "public", }); await expect( service.createConnection(company.id, { name: "Internal Pages broker", transport: "mcp_remote", config: { url: "https://93.184.216.34/mcp", tokenBroker: { enabled: true, tokenUrl: "http://127.0.0.1:9999/v1/tokens/exchange", }, }, enabled: true, status: "active", }), ).resolves.toMatchObject({ config: { tokenBroker: { tokenUrl: "http://127.0.0.1:9999/v1/tokens/exchange" }, }, }); }); it("lists testable agents without calculating every agent's access summary", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const actor = boardSessionActor(company.id, "operator", userId); const agent = await createAgent(db, company.id); await createAgent(db, company.id, "terminated"); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Allow test connection ${randomUUID()}`, policyType: "allow", priority: 100, selectors: { connectionId: connection.id }, }); const app = createRouteApp( db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); const res = await request(app) .get(`/api/tool-connections/${connection.id}/test-agents`) .expect(200); expect(res.body.agents).toHaveLength(1); expect(res.body.agents[0]).toMatchObject({ id: agent.id, orgDepth: 0, }); expect(res.body.agents[0]).not.toHaveProperty("effectiveAccess"); const accessRes = await request(app) .get( `/api/tool-connections/${connection.id}/test-agents/${agent.id}/access`, ) .expect(200); expect(accessRes.body.access).toMatchObject({ connectionId: connection.id, toolCount: 1, allowedCount: 1, askFirstCount: 0, offCount: 0, }); }); it("lists only writable agents and ranks the highest accessible agent first", async () => { const company = await createCompany(db); const userId = `scoped-tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"], "viewer"); const actor = boardSessionActor(company.id, "viewer", userId); const root = await createAgent(db, company.id); const [accessibleManager] = await db .insert(agents) .values({ companyId: company.id, name: "Accessible manager", role: "manager", reportsTo: root.id, status: "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const [accessibleReport] = await db .insert(agents) .values({ companyId: company.id, name: "Accessible report", role: "engineer", reportsTo: accessibleManager!.id, status: "active", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "user", principalId: userId, permissionKey: "tasks:assign_scope", scope: { agentIds: [accessibleManager!.id, accessibleReport!.id] }, grantedByUserId: "owner", }); const { connection } = await createRemoteToolFixture(db, company.id); const app = createRouteApp( db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); const res = await request(app) .get(`/api/tool-connections/${connection.id}/test-agents`) .expect(200); expect(res.body.agents.map((agent: { id: string }) => agent.id)).toEqual([ accessibleManager!.id, accessibleReport!.id, ]); expect( res.body.agents.map((agent: { orgDepth: number }) => agent.orgDepth), ).toEqual([1, 2]); expect(res.body.agents).not.toEqual( expect.arrayContaining([expect.objectContaining({ id: root.id })]), ); }); it("surfaces a last-changed audit hint attributed to the agent that authored the governing policy", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const actor = boardSessionActor(company.id, "operator", userId); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Allow with author ${randomUUID()}`, policyType: "allow", priority: 100, selectors: { connectionId: connection.id }, createdByAgentId: agent.id, }); const app = createRouteApp( db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); const res = await request(app) .get( `/api/tool-connections/${connection.id}/test-agents/${agent.id}/access`, ) .expect(200); const summary = res.body.access; expect(typeof summary.lastChangedAt).toBe("string"); expect(summary.lastChangedByAgentId).toBe(agent.id); expect(summary.lastChangedByName).toBe(agent.name); }); it("executes allowed test calls as a board user while attributing the selected agent", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Allow test call ${randomUUID()}`, policyType: "allow", priority: 100, selectors: { connectionId: connection.id }, }); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-tool-test", result: { content: [{ type: "text", text: "sent" }] }, }), ); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); const res = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com", body: "hi" }, }) .expect(200); expect(res.body).toMatchObject({ decision: "allowed", result: { data: expect.objectContaining({ isError: false, transport: "mcp_http", }), }, }); expect(fetchMock).toHaveBeenCalledOnce(); const [invocation] = await db .select() .from(toolInvocations) .where(eq(toolInvocations.companyId, company.id)); expect(invocation).toMatchObject({ actorType: "user", actorId: userId, agentId: agent.id, runId: null, status: "succeeded", }); const audits = await db .select() .from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.companyId, company.id)); expect(audits).toEqual( expect.arrayContaining([ expect.objectContaining({ actorType: "user", actorId: userId, action: "call_completed", details: expect.objectContaining({ source: "test", agentId: agent.id, runId: null, }), }), ]), ); }); it("turns ask-first test calls into real pending action requests", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Ask first ${randomUUID()}`, policyType: "require_approval", priority: 100, selectors: { connectionId: connection.id }, }); const fetchMock = vi.spyOn(globalThis, "fetch"); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); const res = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" }, }) .expect(200); expect(res.body).toMatchObject({ decision: "ask_first", actionRequestId: expect.any(String), }); expect(fetchMock).not.toHaveBeenCalled(); const [actionRequest] = await db .select() .from(toolActionRequests) .where(eq(toolActionRequests.id, res.body.actionRequestId)); expect(actionRequest).toMatchObject({ companyId: company.id, issueId: null, status: "pending", requestedByUserId: userId, requestedByAgentId: null, }); expect(actionRequest!.signedArguments).toBeTruthy(); const events = await db .select() .from(toolCallEvents) .where(eq(toolCallEvents.companyId, company.id)); expect(events).toEqual( expect.arrayContaining([ expect.objectContaining({ eventType: "approval_requested", actionRequestId: actionRequest!.id, metadata: expect.objectContaining({ source: "test" }), }), ]), ); }); it("cancels an ask-first test request when approval signing is unavailable", async () => { vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", ""); const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Ask first without signing ${randomUUID()}`, policyType: "require_approval", priority: 100, selectors: { connectionId: connection.id }, }); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), createToolGatewayService(db, { toolActionSigningSecret: " " }), ); const res = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" }, }) .expect(500); expect(res.body).toMatchObject({ reasonCode: "signing_secret_unconfigured", error: expect.stringContaining("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET"), }); const [actionRequest] = await db .select() .from(toolActionRequests) .where(eq(toolActionRequests.companyId, company.id)); expect(actionRequest).toMatchObject({ status: "cancelled", signedArguments: null, }); const [invocation] = await db .select() .from(toolInvocations) .where(eq(toolInvocations.companyId, company.id)); expect(invocation).toMatchObject({ status: "failed", errorCode: "signing_secret_unconfigured", }); }); it("audits ask-first test calls with the real board actor and selected agent", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Ask first ${randomUUID()}`, policyType: "require_approval", priority: 100, selectors: { connectionId: connection.id }, }); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); const res = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" }, }) .expect(200); const gatewayAudit = await db .select() .from(activityLog) .where( and( eq(activityLog.companyId, company.id), eq(activityLog.action, "tool_gateway.approval_requested"), ), ); expect(gatewayAudit).toEqual( expect.arrayContaining([ expect.objectContaining({ actorType: "user", actorId: userId, agentId: agent.id, details: expect.objectContaining({ source: "test", actionRequestId: res.body.actionRequestId, invocationId: res.body.invocationId, }), }), ]), ); const dedicatedAudit = await db .select() .from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.companyId, company.id)); expect(dedicatedAudit).toEqual( expect.arrayContaining([ expect.objectContaining({ actorType: "user", actorId: userId, details: expect.objectContaining({ source: "test", agentId: agent.id, actionRequestId: res.body.actionRequestId, runId: null, }), }), ]), ); }); it("drives an ask-first test call through its live lifecycle (waiting → approved/done with the real result)", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Ask first ${randomUUID()}`, policyType: "require_approval", priority: 100, selectors: { connectionId: connection.id }, }); const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret", }); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), gateway, ); // 1. Park the call as a pending action request. const created = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com", body: "hi" }, }) .expect(200); const actionRequestId = created.body.actionRequestId as string; expect(actionRequestId).toEqual(expect.any(String)); // 2. Status starts as "waiting" and surfaces the redacted "Where" snapshot. const waiting = await request(app) .get( `/api/tool-connections/${connection.id}/test-calls/${actionRequestId}`, ) .expect(200); expect(waiting.body).toMatchObject({ actionRequestId, phase: "waiting" }); expect(waiting.body.parameters).toHaveProperty("to"); expect(waiting.body.result).toBeUndefined(); // 3. Approving from the review queue is what runs the parked test call. const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-tool-test", result: { content: [{ type: "text", text: "sent" }] }, }), ); await gateway.approveActionRequest({ companyId: company.id, actionRequestId, actor: { userId }, }); expect(fetchMock).toHaveBeenCalled(); // 4. Status mutates into the completed result shape with the real response. const done = await request(app) .get( `/api/tool-connections/${connection.id}/test-calls/${actionRequestId}`, ) .expect(200); expect(done.body.phase).toBe("done"); expect(done.body.error).toBeUndefined(); expect(done.body.result).toBeDefined(); expect(typeof done.body.durationMs).toBe("number"); const [invocation] = await db .select() .from(toolInvocations) .where(eq(toolInvocations.companyId, company.id)); expect(invocation).toMatchObject({ status: "succeeded", approvalState: "approved", }); }); it("creates a fresh ask-first request when the Test tab reruns the same side-effecting action", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Ask first ${randomUUID()}`, policyType: "require_approval", priority: 100, selectors: { connectionId: connection.id }, }); const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret", }); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), gateway, ); const body = { agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com", body: "hi" }, }; const first = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send(body) .expect(200); vi.spyOn(globalThis, "fetch").mockResolvedValue( mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-tool-test", result: { content: [{ type: "text", text: "sent" }] }, }), ); await gateway.approveActionRequest({ companyId: company.id, actionRequestId: first.body.actionRequestId as string, actor: { userId }, }); const second = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send(body) .expect(200); expect(second.body).toMatchObject({ decision: "ask_first", actionRequestId: expect.any(String), }); expect(second.body.actionRequestId).not.toBe(first.body.actionRequestId); const requests = await db .select() .from(toolActionRequests) .where(eq(toolActionRequests.companyId, company.id)); expect(requests).toHaveLength(2); expect(requests.map((row) => row.status).sort()).toEqual([ "approved", "pending", ]); }); it("reports a denied ask-first test call as denied without running the tool", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Ask first ${randomUUID()}`, policyType: "require_approval", priority: 100, selectors: { connectionId: connection.id }, }); const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret", }); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), gateway, ); const created = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" }, }) .expect(200); const actionRequestId = created.body.actionRequestId as string; const fetchMock = vi.spyOn(globalThis, "fetch"); await gateway.declineActionRequest({ companyId: company.id, actionRequestId, actor: { userId }, }); expect(fetchMock).not.toHaveBeenCalled(); const denied = await request(app) .get( `/api/tool-connections/${connection.id}/test-calls/${actionRequestId}`, ) .expect(200); expect(denied.body.phase).toBe("denied"); expect(denied.body.result).toBeUndefined(); const [invocation] = await db .select() .from(toolInvocations) .where(eq(toolInvocations.companyId, company.id)); expect(invocation).toMatchObject({ status: "denied", approvalState: "rejected", }); }); it("404s a single-id test-call status fetch for a non-test-origin action request", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const { connection } = await createRemoteToolFixture(db, company.id); const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret", }); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), gateway, ); await request(app) .get(`/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`) .expect(404); }); it("returns off for blocked test calls without executing the remote tool", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Block ${randomUUID()}`, policyType: "block", priority: 100, selectors: { connectionId: connection.id }, }); const fetchMock = vi.spyOn(globalThis, "fetch"); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); const res = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" }, }) .expect(200); expect(res.body).toMatchObject({ decision: "off", error: { reasonCode: "deny_policy_block" }, }); expect(fetchMock).not.toHaveBeenCalled(); const [invocation] = await db .select() .from(toolInvocations) .where(eq(toolInvocations.companyId, company.id)); expect(invocation).toMatchObject({ status: "denied", errorCode: "deny_policy_block", actorType: "user", actorId: userId, agentId: agent.id, runId: null, }); }); it("audits blocked test calls with the real board actor and selected agent", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Block ${randomUUID()}`, policyType: "block", priority: 100, selectors: { connectionId: connection.id }, }); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); const res = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" }, }) .expect(200); const gatewayAudit = await db .select() .from(activityLog) .where( and( eq(activityLog.companyId, company.id), eq(activityLog.action, "tool_gateway.call_denied"), ), ); expect(gatewayAudit).toEqual( expect.arrayContaining([ expect.objectContaining({ actorType: "user", actorId: userId, agentId: agent.id, details: expect.objectContaining({ source: "test", invocationId: res.body.invocationId, reasonCode: "deny_policy_block", }), }), ]), ); const dedicatedAudit = await db .select() .from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.companyId, company.id)); expect(dedicatedAudit).toEqual( expect.arrayContaining([ expect.objectContaining({ actorType: "user", actorId: userId, action: "call_denied", reasonCode: "deny_policy_block", details: expect.objectContaining({ source: "test", agentId: agent.id, runId: null, }), }), ]), ); }); it("denies test calls through agents the board user cannot task", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const unassignableAgent = await createAgent(db, company.id, "terminated"); const { connection } = await createRemoteToolFixture(db, company.id); await db.insert(toolPolicies).values({ companyId: company.id, name: `Allow denied impersonation fixture ${randomUUID()}`, policyType: "allow", priority: 100, selectors: { connectionId: connection.id }, }); const fetchMock = vi.spyOn(globalThis, "fetch"); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: unassignableAgent.id, toolName: "send_email", parameters: { to: "a@example.com" }, }) .expect(403); expect(fetchMock).not.toHaveBeenCalled(); await expect( db .select() .from(toolInvocations) .where(eq(toolInvocations.companyId, company.id)), ).resolves.toHaveLength(0); }); it("does not bypass quarantined catalog entries during test calls", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id, { quarantined: true, }); await db.insert(toolPolicies).values({ companyId: company.id, name: `Allow quarantined fixture ${randomUUID()}`, policyType: "allow", priority: 100, selectors: { connectionId: connection.id }, }); const fetchMock = vi.spyOn(globalThis, "fetch"); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); const res = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" }, }) .expect(404); expect(res.body).toMatchObject({ reasonCode: "tool_not_found" }); expect(fetchMock).not.toHaveBeenCalled(); }); it("summarizes profile index counts and restores archived profiles through update", async () => { const company = await createCompany(db); const [agentOne, agentTwo] = await db .insert(agents) .values([ { companyId: company.id, name: `Profile Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }, { companyId: company.id, name: `Profile Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }, ]) .returning(); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, applicationKey: `summary-app-${randomUUID()}`, name: "Summary app", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application!.id, name: "Summary connection", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, }) .returning(); const [readEntry, writeEntry] = await db .insert(toolCatalogEntries) .values([ { companyId: company.id, applicationId: application!.id, connectionId: connection!.id, name: "read_notes", toolName: "read_notes", riskLevel: "read", status: "active", versionHash: randomUUID(), schemaHash: randomUUID(), }, { companyId: company.id, applicationId: application!.id, connectionId: connection!.id, name: "send_email", toolName: "send_email", riskLevel: "write", status: "active", versionHash: randomUUID(), schemaHash: randomUUID(), }, ]) .returning(); const service = createTestToolAccessService(db); const profile = await service.createProfile(company.id, { profileKey: `profile-${randomUUID()}`, name: "All except write tools", defaultAction: "allow", entries: [ { selectorType: "tool_name", effect: "exclude", toolName: "send_email", }, ], }); await service.bindProfile( profile.id, { targetType: "company", targetId: company.id }, { actorType: "user", actorId: "board" }, ); const [listed] = await service.listProfiles(company.id); expect(listed).toMatchObject({ id: profile.id, status: "active", summary: { accessMode: "all_except", allowedToolCount: 1, allowedApplicationCount: 1, excludedToolCount: 1, totalToolCount: 2, assignmentCount: 1, appliesToAgentCount: 2, isCompanyDefault: true, }, }); await expect( service.getEffectiveProfilesForAgent(company.id, agentOne!.id), ).resolves.toMatchObject({ allowedTools: [ expect.objectContaining({ id: readEntry!.id, toolName: "read_notes" }), ], allowedToolNames: ["read_notes"], }); const archived = await service.updateProfile(profile.id, { status: "archived", }); expect(archived.status).toBe("archived"); await expect( service.getEffectiveProfilesForAgent(company.id, agentTwo!.id), ).resolves.toMatchObject({ profiles: [], allowedTools: [], allowedToolNames: [], }); const restored = await service.updateProfile(profile.id, { status: "active", }); expect(restored.status).toBe("active"); await expect( service.getEffectiveProfilesForAgent(company.id, agentTwo!.id), ).resolves.toMatchObject({ allowedTools: [expect.objectContaining({ id: readEntry!.id })], allowedToolNames: ["read_notes"], }); expect(writeEntry).toBeDefined(); }); it("shows only the narrowest matching tier in effective agent previews", async () => { const company = await createCompany(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: `Scoped Preview Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, applicationKey: `preview-app-${randomUUID()}`, name: "Preview app", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application!.id, name: "Preview connection", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, }) .returning(); await db.insert(toolCatalogEntries).values({ companyId: company.id, applicationId: application!.id, connectionId: connection!.id, name: "send_email", toolName: "send_email", riskLevel: "write", status: "active", versionHash: randomUUID(), schemaHash: randomUUID(), }); const service = createTestToolAccessService(db); const [companyProfile, agentProfile] = await Promise.all([ service.createProfile(company.id, { profileKey: `company-default-${randomUUID()}`, name: "Company default", defaultAction: "deny", entries: [ { selectorType: "tool_name", effect: "include", toolName: "send_email", }, ], }), service.createProfile(company.id, { profileKey: `agent-override-${randomUUID()}`, name: "Agent override", defaultAction: "deny", }), ]); await service.bindProfile( companyProfile.id, { targetType: "company", targetId: company.id, priority: 100 }, { actorType: "user", actorId: "board" }, ); await service.bindProfile( agentProfile.id, { targetType: "agent", targetId: agent!.id, priority: 10 }, { actorType: "user", actorId: "board" }, ); const effective = await service.getEffectiveProfilesForAgent( company.id, agent!.id, ); expect(effective.profiles.map((profile) => profile.id)).toEqual([ agentProfile.id, ]); expect( effective.bindings.map( (binding) => `${binding.targetType}:${binding.targetId}`, ), ).toEqual([`agent:${agent!.id}`]); expect(effective.allowedTools).toEqual([]); expect(effective.allowedToolNames).toEqual([]); }); it("prefers agent-scoped allows over broader company defaults in previews", async () => { const company = await createCompany(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: `Scoped Allow Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, applicationKey: `allow-app-${randomUUID()}`, name: "Allow app", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application!.id, name: "Allow connection", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, }) .returning(); await db.insert(toolCatalogEntries).values([ { companyId: company.id, applicationId: application!.id, connectionId: connection!.id, name: "read_notes", toolName: "read_notes", riskLevel: "read", status: "active", versionHash: randomUUID(), schemaHash: randomUUID(), }, { companyId: company.id, applicationId: application!.id, connectionId: connection!.id, name: "send_email", toolName: "send_email", riskLevel: "write", status: "active", versionHash: randomUUID(), schemaHash: randomUUID(), }, ]); const service = createTestToolAccessService(db); const [companyProfile, agentProfile] = await Promise.all([ service.createProfile(company.id, { profileKey: `company-read-${randomUUID()}`, name: "Company read", defaultAction: "deny", entries: [ { selectorType: "tool_name", effect: "include", toolName: "read_notes", }, ], }), service.createProfile(company.id, { profileKey: `agent-write-${randomUUID()}`, name: "Agent write", defaultAction: "deny", entries: [ { selectorType: "tool_name", effect: "include", toolName: "send_email", }, ], }), ]); await service.bindProfile( companyProfile.id, { targetType: "company", targetId: company.id, priority: 100 }, { actorType: "user", actorId: "board" }, ); await service.bindProfile( agentProfile.id, { targetType: "agent", targetId: agent!.id, priority: 10 }, { actorType: "user", actorId: "board" }, ); const effective = await service.getEffectiveProfilesForAgent( company.id, agent!.id, ); expect(effective.profiles.map((profile) => profile.id)).toEqual([ agentProfile.id, ]); expect(effective.allowedToolNames).toEqual(["send_email"]); }); it("duplicates profiles with entries and optional assignments", async () => { const company = await createCompany(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: `Duplicate Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const service = createTestToolAccessService(db); const profile = await service.createProfile(company.id, { profileKey: `profile-${randomUUID()}`, name: "Email tools source", defaultAction: "allow", entries: [ { selectorType: "tool_name", effect: "exclude", toolName: "delete_email", }, ], }); await service.bindProfile( profile.id, { targetType: "agent", targetId: agent!.id, priority: 25 }, { actorType: "user", actorId: "board" }, ); const unassignedCopy = await service.duplicateProfile(profile.id, { name: "Email tools unassigned copy", includeAssignments: false, }); expect(unassignedCopy).toMatchObject({ name: "Email tools unassigned copy", status: "active", defaultAction: "allow", entries: [ expect.objectContaining({ selectorType: "tool_name", effect: "exclude", toolName: "delete_email", }), ], bindings: [], summary: expect.objectContaining({ assignmentCount: 0 }), }); const assignedCopy = await service.duplicateProfile(profile.id, { name: "Email tools assigned copy", includeAssignments: true, }); expect(assignedCopy).toMatchObject({ name: "Email tools assigned copy", status: "active", bindings: [ expect.objectContaining({ targetType: "agent", targetId: agent!.id, priority: 25, }), ], summary: expect.objectContaining({ assignmentCount: 1, appliesToAgentCount: 1, }), }); }); it("deletes profiles with cascades and guards company defaults", async () => { const company = await createCompany(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: `Delete Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const service = createTestToolAccessService(db); const profile = await service.createProfile(company.id, { profileKey: `profile-${randomUUID()}`, name: "Delete source", entries: [ { selectorType: "tool_name", effect: "include", toolName: "send_email", }, ], }); await service.bindProfile( profile.id, { targetType: "agent", targetId: agent!.id }, { actorType: "user", actorId: "board" }, ); const deleted = await service.deleteProfile(profile.id, { force: false }); expect(deleted).toMatchObject({ profile: expect.objectContaining({ id: profile.id }), summary: expect.objectContaining({ assignmentCount: 1, appliesToAgentCount: 1, }), reassignedToProfileId: null, }); await expect(service.getProfile(profile.id)).rejects.toMatchObject({ status: 404, }); await expect( db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, profile.id)), ).resolves.toEqual([]); await expect( db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, profile.id)), ).resolves.toEqual([]); const defaultProfile = await service.createProfile(company.id, { profileKey: `default-profile-${randomUUID()}`, name: "Company default delete guard", defaultAction: "allow", }); await service.bindProfile( defaultProfile.id, { targetType: "company", targetId: company.id }, { actorType: "user", actorId: "board" }, ); await expect( service.deleteProfile(defaultProfile.id, { force: false }), ).rejects.toMatchObject({ status: 422, details: { summary: expect.objectContaining({ isCompanyDefault: true, assignmentCount: 1, appliesToAgentCount: 1, }), }, }); await expect( service.deleteProfile(defaultProfile.id, { force: true }), ).resolves.toMatchObject({ profile: expect.objectContaining({ id: defaultProfile.id }), summary: expect.objectContaining({ isCompanyDefault: true }), }); }); it("keeps duplicate, delete, and new-tools profile routes board-only and viewer-safe", async () => { const company = await createCompany(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: `Route Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const service = createTestToolAccessService(db); const profile = await service.createProfile(company.id, { profileKey: `route-profile-${randomUUID()}`, name: "Route profile", defaultAction: "deny", }); const agentApp = createRouteApp(db, { type: "agent", companyId: company.id, agentId: agent.id, runId: null, source: "agent_jwt", }); const viewerApp = createRouteApp( db, boardSessionActor(company.id, "viewer"), ); const viewerRead = await request(viewerApp).get( `/api/tool-profiles/${profile.id}/new-tools`, ); expect(viewerRead.status).toBe(200); expect(viewerRead.body).toMatchObject({ profileId: profile.id, pendingCount: 0, tools: [], }); await request(agentApp) .get(`/api/tool-profiles/${profile.id}/new-tools`) .expect(403); await request(agentApp) .post(`/api/tool-profiles/${profile.id}/duplicate`) .send({ name: "Agent copy", includeAssignments: true }) .expect(403); await request(agentApp) .delete(`/api/tool-profiles/${profile.id}`) .send({ force: false }) .expect(403); await request(agentApp) .post(`/api/tool-profiles/${profile.id}/new-tools/review`) .send({ decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }], }) .expect(403); await request(viewerApp) .post(`/api/tool-profiles/${profile.id}/duplicate`) .send({ name: "Viewer copy", includeAssignments: true }) .expect(403); await request(viewerApp) .delete(`/api/tool-profiles/${profile.id}`) .send({ force: false }) .expect(403); await request(viewerApp) .post(`/api/tool-profiles/${profile.id}/new-tools/review`) .send({ decisions: [{ catalogEntryId: randomUUID(), decision: "keep_blocked" }], }) .expect(403); }); it("returns 404 for cross-company profile routes and missing profiles", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); const service = createTestToolAccessService(db); const profile = await service.createProfile(otherCompany.id, { profileKey: `other-profile-${randomUUID()}`, name: "Other company profile", defaultAction: "deny", }); const entry = await service.addProfileEntry(profile.id, { selectorType: "tool_name", effect: "include", toolName: "read_notes", }); const app = createRouteApp(db, { type: "board", userId: "member-user", userName: "Member User", userEmail: null, companyIds: [allowedCompany.id], memberships: [ { companyId: allowedCompany.id, membershipRole: "owner", status: "active", }, ], isInstanceAdmin: false, source: "session", }); const crossTenantResponses = [ await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`), await request(app) .patch(`/api/tool-profiles/${profile.id}`) .send({ name: "Cross-tenant edit" }), await request(app) .post(`/api/tool-profiles/${profile.id}/duplicate`) .send({ name: "Copy" }), await request(app).delete(`/api/tool-profiles/${profile.id}`).send({}), await request(app) .post(`/api/tool-profiles/${profile.id}/new-tools/review`) .send({ decisions: [ { catalogEntryId: randomUUID(), decision: "keep_blocked" }, ], }), await request(app) .post(`/api/tool-profiles/${profile.id}/entries`) .send({ selectorType: "tool_name", effect: "include", toolName: "write_notes", }), await request(app) .patch(`/api/tool-profile-entries/${entry.id}`) .send({ effect: "exclude" }), await request(app).delete(`/api/tool-profile-entries/${entry.id}`), ]; const missingRes = await request(app).get( `/api/tool-profiles/${randomUUID()}/new-tools`, ); expect(crossTenantResponses.map((response) => response.status)).toEqual( crossTenantResponses.map(() => 404), ); expect(missingRes.status).toBe(404); expect(crossTenantResponses[0]!.body).toEqual(missingRes.body); }); it("returns 404 for cross-company connection routes, including instance admins", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); const service = createTestToolAccessService(db); const connection = await service.createConnection(otherCompany.id, { name: "Other company connection", transport: "mcp_remote", config: { url: "https://other-company.example/mcp" }, }); const oauthState = randomUUID(); await db.insert(toolOauthStates).values({ state: oauthState, companyId: otherCompany.id, connectionId: connection.id, codeVerifier: "cross-tenant-code-verifier", createdByActorType: "user", createdByActorId: "other-user", createdBySessionId: null, expiresAt: new Date(Date.now() + 60_000), }); const toolGateway = {} as ToolGatewayService; const app = createRouteApp( db, { type: "board", userId: "member-user", userName: "Member User", userEmail: null, companyIds: [allowedCompany.id], memberships: [ { companyId: allowedCompany.id, membershipRole: "owner", status: "active", }, ], isInstanceAdmin: true, source: "session", }, toolGateway, ); const foreignOAuthRes = await request(app) .get("/api/tools/oauth/callback") .query({ state: oauthState, code: "oauth-code" }); const missingOAuthRes = await request(app) .get("/api/tools/oauth/callback") .query({ state: randomUUID(), code: "oauth-code" }); const crossTenantResponses = [ await request(app).post(`/api/tools/oauth/${connection.id}/start`), await request(app).get(`/api/tool-connections/${connection.id}`), await request(app).get(`/api/tool-connections/${connection.id}/grants`), await request(app) .post(`/api/tool-connections/${connection.id}/grants/installations`) .send({}), await request(app).delete( `/api/tool-connections/${connection.id}/grants/${randomUUID()}`, ), await request(app).get(`/api/tool-connections/${connection.id}/usage`), await request(app).get(`/api/tool-connections/${connection.id}/installs`), await request(app) .put(`/api/tool-connections/${connection.id}/installs`) .send({ installs: [] }), await request(app).get( `/api/tool-connections/${connection.id}/test-agents`, ), await request(app).get( `/api/tool-connections/${connection.id}/test-agents/${randomUUID()}/access`, ), await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: randomUUID(), toolName: "read_notes", parameters: {}, }), await request(app).get( `/api/tool-connections/${connection.id}/test-calls/${randomUUID()}`, ), await request(app) .patch(`/api/tool-connections/${connection.id}`) .send({ name: "Cross-tenant edit" }), await request(app).delete(`/api/tool-connections/${connection.id}`), await request(app).post( `/api/tool-connections/${connection.id}/health-check`, ), await request(app) .post(`/api/tool-connections/${connection.id}/reconnect`) .send({ credentialValues: {} }), await request(app).post( `/api/tool-connections/${connection.id}/catalog/refresh`, ), await request(app).get(`/api/tool-connections/${connection.id}/catalog`), await request(app).get( `/api/tool-connections/${connection.id}/activity?limit=5`, ), ]; expect(foreignOAuthRes.status).toBe(400); expect(missingOAuthRes.status).toBe(400); expect(foreignOAuthRes.body).toEqual(missingOAuthRes.body); const missingRes = await request(app).get( `/api/tool-connections/${randomUUID()}`, ); for (const response of crossTenantResponses) { expect(response.status).toBe(404); } expect(missingRes.status).toBe(404); expect(crossTenantResponses[1]!.body).toEqual(missingRes.body); await expect(service.getConnection(connection.id)).resolves.toMatchObject({ id: connection.id, }); }); it("installs the safe example fixture idempotently and smokes allow, deny, and audit paths", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const before = await service.listExamples(company.id); expect(before).toEqual([ expect.objectContaining({ id: "safe-read-only-todo-kv", install: expect.objectContaining({ installed: false, canInstall: true, }), }), ]); const install = await service.installExample( company.id, "safe-read-only-todo-kv", { actorType: "user", actorId: "board", }, ); const secondInstall = await service.installExample( company.id, "safe-read-only-todo-kv", { actorType: "user", actorId: "board", }, ); expect(install.created).toBe(true); expect(secondInstall.created).toBe(false); expect(install.application).toMatchObject({ applicationKey: "paperclip.examples.safe-read-only-todo-kv", type: "mcp_stdio", status: "active", }); expect(install.connection).toMatchObject({ transport: "local_stdio", status: "active", enabled: true, config: expect.objectContaining({ templateId: "paperclip.synthetic-todo-kv", }), }); expect(install.profile).toMatchObject({ profileKey: "paperclip.examples.safe-read-only-todo-kv.profile", defaultAction: "deny", status: "active", }); expect(install.profileBinding).toMatchObject({ targetType: "company", targetId: company.id, }); expect( install.profileEntries.map((entry) => entry.toolName).sort(), ).toEqual(["get_value", "list_items"]); const installedCatalogByTool = new Map( install.catalog.map((entry) => [entry.toolName, entry]), ); expect(installedCatalogByTool.get("list_items")).toMatchObject({ status: "active", riskLevel: "read", }); expect(installedCatalogByTool.get("set_value")).toMatchObject({ status: "quarantined", riskLevel: "write", }); const smoke = await service.smokeExample( company.id, "safe-read-only-todo-kv", { actorType: "user", actorId: "board", }, ); expect(smoke.ok).toBe(true); expect(smoke.checks).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "allow_read_tool", ok: true, decision: "allow", reasonCode: "allow_profile", }), expect.objectContaining({ name: "deny_write_tool", ok: true, decision: "deny", reasonCode: "deny_default", }), expect.objectContaining({ name: "audit_written", ok: true }), ]), ); const auditRows = await db .select() .from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.companyId, company.id)); expect( auditRows.some( (row) => row.action === "tool_access.policy_decision" && row.reasonCode === "allow_profile", ), ).toBe(true); expect( auditRows.some( (row) => row.action === "tool_access.policy_decision" && row.reasonCode === "deny_default", ), ).toBe(true); }); it("evaluates enabled tool policies by priority with first-match wins", async () => { const company = await createCompany(db); const policyService = toolAccessPolicyService(db); const [allowPolicy, blockPolicy] = await db .insert(toolPolicies) .values([ { companyId: company.id, name: `Allow first ${randomUUID()}`, policyType: "allow", priority: 100, selectors: { toolName: "fixture:dangerous_action" }, }, { companyId: company.id, name: `Block second ${randomUUID()}`, policyType: "block", priority: 200, selectors: { toolName: "fixture:dangerous_action" }, }, ]) .returning(); const allowDecision = await policyService.decide({ companyId: company.id, actor: { actorType: "user", actorId: "board-user" }, request: { toolName: "fixture:dangerous_action", arguments: {} }, }); expect(allowDecision).toMatchObject({ decision: "allow", reasonCode: "allow_policy", matchedPolicyIds: [allowPolicy!.id], }); await policyService.reorderPolicies(company.id, { policyIds: [blockPolicy!.id, allowPolicy!.id], }); const blockDecision = await policyService.decide({ companyId: company.id, actor: { actorType: "user", actorId: "board-user" }, request: { toolName: "fixture:dangerous_action", arguments: {} }, }); expect(blockDecision).toMatchObject({ decision: "deny", reasonCode: "deny_policy_block", matchedPolicyIds: [blockPolicy!.id], }); }); it("reorders and duplicates policies through board routes", async () => { const company = await createCompany(db); const [first, second] = await db .insert(toolPolicies) .values([ { companyId: company.id, name: `First policy ${randomUUID()}`, policyType: "allow", priority: 100, selectors: { toolName: "read_notes" }, }, { companyId: company.id, name: `Second policy ${randomUUID()}`, policyType: "block", priority: 200, selectors: { toolName: "delete_notes" }, }, ]) .returning(); const app = createRouteApp(db); const reorder = await request(app) .post(`/api/companies/${company.id}/tools/policies/reorder`) .send({ policyIds: [second!.id, first!.id] }); expect(reorder.status).toBe(200); expect( reorder.body.policies.map((policy: { id: string; priority: number }) => [ policy.id, policy.priority, ]), ).toEqual([ [second!.id, 100], [first!.id, 200], ]); const duplicate = await request(app) .post( `/api/companies/${company.id}/tools/policies/${first!.id}/duplicate`, ) .send({}); expect(duplicate.status).toBe(201); expect(duplicate.body).toMatchObject({ name: `${first!.name} copy`, policyType: first!.policyType, enabled: false, selectors: first!.selectors, }); const otherCompany = await createCompany(db); const [foreignPolicy] = await db .insert(toolPolicies) .values({ companyId: otherCompany.id, name: `Foreign policy ${randomUUID()}`, policyType: "allow", priority: 100, selectors: {}, }) .returning(); await request(app) .post(`/api/companies/${company.id}/tools/policies/reorder`) .send({ policyIds: [second!.id, first!.id, foreignPolicy!.id] }) .expect(422); const auditRows = await db .select() .from(activityLog) .where(eq(activityLog.companyId, company.id)); expect(auditRows).toEqual( expect.arrayContaining([ expect.objectContaining({ action: "tool_policy.reordered" }), expect.objectContaining({ action: "tool_policy.duplicated" }), ]), ); }); it("serves the app gallery manifest through the board route", async () => { const company = await createCompany(db); const app = createRouteApp(db, undefined, undefined, { paperclipCloudConnector: null, }); const res = await request(app).get( `/api/companies/${company.id}/tools/gallery`, ); expect(res.status).toBe(200); expect(res.body.capabilities).toEqual({ canCreateOrganizationGrant: true, organizationGrantReason: null, canSetCompanyInstall: true, companyInstallReason: null, }); expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual( expect.arrayContaining([ "agentmail", "imessage-photon", "jira", "airtable", "asana", "notion", "posthog", "sentry", "zapier", "linear", "gmail", "google-drive", "google-docs", "google-sheets", "google-slides", "google-calendar", "google-chat", "google-people", "google-workspace-search", "github", ]), ); expect(res.body.apps).toHaveLength(46); expect( res.body.apps.find((app: { slug: string }) => app.slug === "gmail") .ownershipAvailability, ).toEqual({ platform_shared: false, platform_provisioned: false, customer: true, dcr: true, }); const gallerySlugs = new Set( res.body.apps.map((app: { slug: string }) => app.slug), ); expect( [...APP_STORE_HIDDEN_SLUGS].filter((slug) => gallerySlugs.has(slug)), ).toEqual([]); expect( ["g2", "vercel", "zomato"].filter((slug) => gallerySlugs.has(slug)), ).toEqual([]); expect(res.body.apps).toEqual( expect.arrayContaining([ expect.objectContaining({ slug: "posthog", methods: expect.arrayContaining([ expect.objectContaining({ key: "mcp-oauth", auth: "oauth" }), expect.objectContaining({ key: "mcp-api-key", auth: "api_key" }), ]), }), expect.objectContaining({ slug: "zapier", methods: expect.arrayContaining([ expect.objectContaining({ key: "generated-url", auth: "none", defaults: {}, }), ]), }), expect.objectContaining({ slug: "google-sheets", methods: expect.arrayContaining([ expect.objectContaining({ key: "local", transport: "local_stdio" }), ]), }), ]), ); }); it("exposes managed Google methods only for profiles signed for this enrolled instance", async () => { const company = await createCompany(db); const userId = `gallery-pilot-${randomUUID()}`; const pilotConnector = fakeGoogleWorkspaceConnector( company.id, userId, "gmail.read", ); const nonPilotConnector: PaperclipCloudConnector = { ...pilotConnector, getCapabilities: vi.fn(async () => []), }; const nonPilot = await request( createRouteApp( db, boardSessionActor(company.id, "owner", userId), undefined, { paperclipCloudConnector: nonPilotConnector }, ), ).get(`/api/companies/${company.id}/tools/gallery`); expect(nonPilot.status).toBe(200); const nonPilotGmail = nonPilot.body.apps.find( (app: { slug: string }) => app.slug === "gmail", ); expect(nonPilotGmail.ownershipAvailability.platform_shared).toBe(false); expect( nonPilotGmail.methods.some( (method: { oauthStrategy?: string }) => method.oauthStrategy === "paperclip_cloud_connector", ), ).toBe(false); expect( nonPilotGmail.methods.map((method: { key: string }) => method.key), ).toEqual(["customer-read-oauth", "customer-draft-oauth"]); const pilot = await request( createRouteApp( db, boardSessionActor(company.id, "owner", userId), undefined, { paperclipCloudConnector: pilotConnector }, ), ).get(`/api/companies/${company.id}/tools/gallery`); expect(pilot.status).toBe(200); const pilotGmail = pilot.body.apps.find( (app: { slug: string }) => app.slug === "gmail", ); expect(pilotGmail.ownershipAvailability.platform_shared).toBe(true); expect( pilotGmail.methods.map((method: { key: string }) => method.key), ).toEqual([ "paperclip-read", "customer-read-oauth", "customer-draft-oauth", ]); }); it("preflights only public Jira metadata without credentials or OAuth registration", async () => { const requests: Array<{ url: string; method: string; hasAuthorization: boolean; }> = []; const service = createTestToolAccessService(db, { now: () => new Date("2026-08-26T12:00:00.000Z"), remoteHttpRequest: async (url, init) => { const method = (init.method ?? "GET").toUpperCase(); requests.push({ url, method, hasAuthorization: new Headers(init.headers).has("authorization"), }); if (url === "https://mcp.atlassian.com/v1/mcp/authv2") { return new Response(null, { status: 401, headers: { "content-type": "application/json" }, }); } if (url.includes("oauth-protected-resource")) { return new Response( JSON.stringify({ resource: "https://mcp.atlassian.com/v1/mcp/authv2", authorization_servers: ["https://auth.atlassian.example"], }), { status: 200, headers: { "content-type": "application/json" } }, ); } if (url.startsWith("https://auth.atlassian.example/")) { return new Response( JSON.stringify({ issuer: "https://auth.atlassian.example", authorization_endpoint: "https://auth.atlassian.example/authorize", token_endpoint: "https://auth.atlassian.example/token", registration_endpoint: "https://auth.atlassian.example/register", }), { status: 200, headers: { "content-type": "application/json" } }, ); } return new Response(null, { status: 404 }); }, }); const result = await service.preflightGalleryAppMetadata( "jira", "mcp-oauth", ); expect(result).toMatchObject({ galleryKey: "jira", methodKey: "mcp-oauth", serverUrl: "https://mcp.atlassian.com/v1/mcp/authv2", endpointReachable: true, oauth: { metadataFound: true, registrationAdvertised: true, clientIdMetadataDocumentSupported: false, }, checkedAt: "2026-08-26T12:00:00.000Z", }); expect(requests.length).toBeGreaterThan(2); expect( requests.every( (request) => request.method === "GET" && !request.hasAuthorization, ), ).toBe(true); expect(requests.some((request) => request.url.endsWith("/register"))).toBe( false, ); }); it("degrades a Composio child when its connected account becomes inactive", async () => { const company = await createCompany(db); const { child } = await createComposioParentAndChild(db, company.id); const client = fakeComposioClient(() => "INACTIVE"); const service = createTestToolAccessService(db, { composioClientFactory: () => client, }); await expect(service.checkHealth(child.id)).rejects.toMatchObject({ status: 502, details: { code: "composio_connected_account_inactive", connection: expect.objectContaining({ id: child.id, healthStatus: "degraded", }), }, }); await expect(service.getConnection(child.id)).resolves.toMatchObject({ healthStatus: "degraded", healthMessage: expect.stringContaining("INACTIVE"), }); expect(client.listConnectedAccounts).toHaveBeenCalledWith( expect.objectContaining({ toolkitSlugs: ["github"], }), ); }); it("cascades Composio parent pause, restores active children, and keeps inactive children disabled", async () => { const company = await createCompany(db); const { parent, child } = await createComposioParentAndChild( db, company.id, ); let accountStatus = "ACTIVE"; const service = createTestToolAccessService(db, { composioClientFactory: () => fakeComposioClient(() => accountStatus), }); await service.updateConnection(parent.id, { enabled: false }); await expect(service.getConnection(child.id)).resolves.toMatchObject({ enabled: false, config: expect.objectContaining({ disabledByComposioParent: true }), }); accountStatus = "INACTIVE"; await service.updateConnection(parent.id, { enabled: true }); await expect(service.getConnection(child.id)).resolves.toMatchObject({ enabled: false, healthStatus: "degraded", healthMessage: expect.stringContaining("INACTIVE"), }); accountStatus = "ACTIVE"; await service.updateConnection(parent.id, { enabled: true }); const restored = await service.getConnection(child.id); expect(restored).toMatchObject({ enabled: true, healthStatus: "unchecked", healthMessage: null, }); expect(restored.config).not.toHaveProperty("disabledByComposioParent"); }); it("requires child-removal confirmation before deleting a Composio parent", async () => { const company = await createCompany(db); const { parent, child } = await createComposioParentAndChild( db, company.id, ); const service = createTestToolAccessService(db); await expect( service.archiveConnection(parent.id, company.id), ).rejects.toMatchObject({ status: 409, details: { code: "composio_child_removal_confirmation_required", childConnectionCount: 1, }, }); await expect( service.archiveConnection(parent.id, company.id, undefined, { confirmComposioChildren: true, }), ).resolves.toMatchObject({ connection: expect.objectContaining({ status: "archived" }), }); await expect(service.getConnection(child.id)).resolves.toMatchObject({ status: "archived", enabled: false, }); }); it("returns server-derived create capabilities for a non-manager member", async () => { const company = await createCompany(db); const app = createRouteApp(db, boardSessionActor(company.id, "member")); const res = await request(app).get( `/api/companies/${company.id}/tools/gallery`, ); expect(res.status).toBe(200); expect(res.body.capabilities).toEqual({ canCreateOrganizationGrant: false, organizationGrantReason: "Only a company owner, administrator, or connection manager can share this credential with the organization.", canSetCompanyInstall: false, companyInstallReason: "Only someone who can configure this connection can choose this.", }); }); it("requires connection-manager authority to create an organization credential", async () => { const company = await createCompany(db); const app = createRouteApp(db, boardSessionActor(company.id, "member")); await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ galleryKey: "notion", grantKind: "organization" }) .expect(403); // Omitted grantKind retains the legacy organization default, so it must // pass through the same authorization check. await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ galleryKey: "notion" }) .expect(403); await expect(db.select().from(toolApplications)).resolves.toHaveLength(0); await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); }); it("requires connection-manager authority to resume an organization credential", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const shared = await service.connectGalleryApp( company.id, { galleryKey: "notion", grantKind: "organization", }, { actorType: "user", actorId: "member" }, ); const app = createRouteApp( db, boardSessionActor(company.id, "member", "member"), ); // The retained shared identity wins over a contradictory personal value. await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ galleryKey: "notion", resumeConnectionId: shared.connectionId, grantKind: "user", }) .expect(403); // Omitting the explicit id must not bypass the same check through the // service's retained draft lookup by application name and source. await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ galleryKey: "notion", grantKind: "user" }) .expect(403); }); it("previews remote mcp.json headers as secret replacement fields without echoing values", async () => { const company = await createCompany(db); const app = createRouteApp(db); const res = await request(app) .post(`/api/companies/${company.id}/tools/mcp/import-json`) .send({ mcpJson: { mcpServers: { secure: { url: "https://secure.example/mcp", headers: { Authorization: "Bearer raw-token", "X-API-Key": "raw-key", }, }, }, }, }); expect(res.status).toBe(200); expect(JSON.stringify(res.body)).not.toContain("raw-token"); expect(JSON.stringify(res.body)).not.toContain("raw-key"); expect(res.body.drafts).toEqual([ expect.objectContaining({ name: "secure", transport: "mcp_remote", status: "draft", config: { url: "https://secure.example/mcp" }, credentialFields: [ expect.objectContaining({ configPath: "headers.Authorization", key: "Authorization", placement: "header", }), expect.objectContaining({ configPath: "headers.X-API-Key", key: "X-API-Key", placement: "header", }), ], }), ]); }); it("creates link-based MCP connections with imported header secrets before catalog review", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (_url, init) => { const headers = init?.headers as Record; expect(headers.Authorization).toBe("Bearer imported-token"); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "kv_get", description: "Read a value.", inputSchema: { type: "object", properties: { key: { type: "string" } }, }, annotations: { readOnlyHint: true }, }, ], }, }); }); const result = await service.connectGalleryApp( company.id, { link: "https://secure.example/mcp", name: "Secure import", credentialValues: { "headers.Authorization": "Bearer imported-token" }, }, { actorType: "user", actorId: "board" }, ); expect(fetchMock).toHaveBeenCalled(); expect(result.connection.status).toBe("draft"); expect(result.connection.credentialRefs).toEqual([ expect.objectContaining({ name: "headers.Authorization", placement: "header", key: "Authorization", prefix: null, }), ]); expect(result.connection.config).toMatchObject({ url: "https://secure.example/mcp", }); expect(JSON.stringify(result.connection.config)).not.toContain( "imported-token", ); expect(result.actions.readOnly).toEqual([ expect.objectContaining({ toolName: "kv_get", riskLevel: "read" }), ]); }); it("initializes stateful Streamable HTTP servers and remembers that future calls need a session", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const requests: Array<{ method: string; sessionId: string | null }> = []; let sessionSequence = 0; vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { const payload = JSON.parse(String(init?.body ?? "{}")) as { method?: string; id?: string; }; const requestHeaders = new Headers(init?.headers); requests.push({ method: payload.method ?? "", sessionId: requestHeaders.get("mcp-session-id"), }); if (payload.method === "initialize") { sessionSequence += 1; return new Response( JSON.stringify({ jsonrpc: "2.0", id: payload.id, result: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "stateful", version: "1" }, }, }), { status: 200, headers: { "content-type": "application/json", "mcp-session-id": `session-${sessionSequence}`, }, }, ); } if (payload.method === "notifications/initialized") { expect(requestHeaders.get("mcp-session-id")).toBeTruthy(); return new Response(null, { status: 202 }); } if ( payload.method === "tools/list" && !requestHeaders.get("mcp-session-id") ) { return new Response( JSON.stringify({ message: "Mcp-Session-Id header is required" }), { status: 400, headers: { "content-type": "application/json" }, }, ); } return mcpHttpResponse({ jsonrpc: "2.0", id: payload.id, result: { tools: [{ name: "list_state", annotations: { readOnlyHint: true } }], }, }); }); const result = await service.connectGalleryApp( company.id, { link: "https://stateful.example/mcp", name: "Stateful MCP", }, { actorType: "user", actorId: "board" }, ); expect(result.connection.config).toMatchObject({ mcpSessionRequired: true, }); expect(result.actions.readOnly).toEqual([ expect.objectContaining({ toolName: "list_state", riskLevel: "read" }), ]); expect(requests[0]).toEqual({ method: "tools/list", sessionId: null }); expect( requests.filter(({ method }) => method === "initialize").length, ).toBeGreaterThanOrEqual(1); expect( requests.filter(({ method }) => method === "notifications/initialized") .length, ).toBe(requests.filter(({ method }) => method === "initialize").length); }); it("serves persisted MCP actions until the cache expires and then refreshes them", async () => { const company = await createCompany(db); let currentTime = new Date("2026-08-20T12:00:00.000Z"); let tools = [ { name: "cached_read", description: "Read the cached value.", annotations: { readOnlyHint: true }, }, ]; const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async () => mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools }, }), ); const service = createTestToolAccessService(db, { now: () => currentTime, catalogCacheTtlMs: 60_000, }); const connected = await service.connectGalleryApp( company.id, { link: "https://cache.example.test/mcp", name: "Cached actions", }, { actorType: "user", actorId: "board" }, ); const discoveryCallsAfterConnect = fetchMock.mock.calls.length; const cached = await service.listCatalog(connected.connectionId); expect(cached.map((entry) => entry.toolName)).toContain("cached_read"); expect(fetchMock).toHaveBeenCalledTimes(discoveryCallsAfterConnect); tools = [ ...tools, { name: "fresh_read", description: "Read a newly discovered value.", annotations: { readOnlyHint: true }, }, ]; currentTime = new Date(currentTime.getTime() + 60_001); const refreshed = await service.listCatalog(connected.connectionId); expect(refreshed.map((entry) => entry.toolName)).toContain("fresh_read"); expect(fetchMock).toHaveBeenCalledTimes(discoveryCallsAfterConnect + 1); currentTime = new Date(currentTime.getTime() + 60_001); fetchMock.mockRejectedValueOnce(new Error("temporary MCP outage")); await expect(service.listCatalog(connected.connectionId)).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "fresh_read" }), ]), ); }); it("commits a 'Just me' key to the caller's own grant and never to the connection or an organization grant", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "query_insight", annotations: { readOnlyHint: true } }, ]); const connected = await service.connectGalleryApp( company.id, { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", credentialValues: { "credentials.authorization": "phx_personal-secret", }, configValues: { projectId: "12345", mode: "tools" }, grantKind: "user", }, { actorType: "user", actorId: "carol" }, ); const { grants } = await service.listConnectionGrants( connected.connectionId, company.id, ); const connection = await service.getConnection( connected.connectionId, company.id, ); // The identity is the caller's, and it is the only grant: creating an // organization grant first and "moving" the secret later is exactly what the // design forbids, so there must be no organization grant at all. expect(grants).toHaveLength(1); expect(grants[0]).toMatchObject({ kind: "user", subjectUserId: "carol", status: "active", }); expect(grants[0]!.credentialSecretRefs.length).toBeGreaterThan(0); expect(grants.some((grant) => grant.kind === "organization")).toBe(false); // The personal secret is not reachable as the connection's shared credential. expect(connection.credentialSecretRefs).toEqual([]); expect(connection.credentialPolicy).toBe("per_user"); // ...and the secret ids the personal grant holds appear nowhere on the row's // shared secret-ref list, which is what an organization grant would copy. const personalSecretIds = new Set( grants[0]!.credentialSecretRefs.map((ref) => ref.secretId), ); for (const ref of connection.credentialSecretRefs) { expect(personalSecretIds.has(ref.secretId)).toBe(false); } }); it("keeps the shared-credential default when no grant kind is chosen", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "query_insight", annotations: { readOnlyHint: true } }, ]); const connected = await service.connectGalleryApp( company.id, { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", credentialValues: { "credentials.authorization": "phx_shared-secret" }, configValues: { projectId: "12345", mode: "tools" }, }, { actorType: "user", actorId: "board" }, ); const { grants } = await service.listConnectionGrants( connected.connectionId, company.id, ); const connection = await service.getConnection( connected.connectionId, company.id, ); expect(grants).toHaveLength(1); expect(grants[0]).toMatchObject({ kind: "organization", isDefault: true }); expect(grants[0]!.credentialSecretRefs.length).toBeGreaterThan(0); expect(connection.credentialPolicy).toBe("shared"); }); it("validates a reviewed Vercel connector and stores no provider bearer or vault secret", async () => { vi.stubEnv("PAPERCLIP_VERCEL_CONNECT_ENABLED", "true"); vi.stubEnv( "PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN", "vercel-bootstrap-authority", ); const company = await createCompany(db); const getToken = vi.fn(async () => ({ token: "posthog-provider-bearer", tokenId: "stk_posthog", expiresAt: Date.now() + 60_000, connector: { id: "scl_posthog", uid: "posthog-paperclip", type: "api-key", }, tenantId: "project-12345", claims: { email: "must-not-persist@example.com" }, metadata: { providerSecret: "must-not-persist" }, })); const revoke = vi.fn(); const vercelConnectClient: VercelConnectClient = { getConnectorMetadata: vi.fn(async () => ({ id: "scl_posthog", uid: "posthog-paperclip", name: "Paperclip PostHog", type: "api-key", service: "mcp.posthog.com/mcp", createdAt: Date.now(), updatedAt: Date.now(), vendor: { arbitrary: "public-but-not-persisted" }, })), getToken, startAuthorization: vi.fn(), revoke, evict: vi.fn(), }; const observedAuthorization: string[] = []; const service = createTestToolAccessService(db, { vercelConnectClient, remoteHttpRequest: async (_url, init) => { observedAuthorization.push( new Headers(init.headers).get("authorization") ?? "", ); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "query_insight", annotations: { readOnlyHint: true } }, ], }, }); }, }); const connected = await service.connectGalleryApp( company.id, { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", credentialSource: "vercel_connect", vercelConnect: { connector: "posthog-paperclip" }, configValues: { projectId: "12345", mode: "tools" }, grantKind: "organization", }, { actorType: "user", actorId: "board" }, ); const [storedConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); const [storedGrant] = await db .select() .from(connectionGrants) .where(eq(connectionGrants.connectionId, connected.connectionId)); const storedSecrets = await db .select() .from(companySecrets) .where(eq(companySecrets.companyId, company.id)); expect(connected.connection).toMatchObject({ credentialSource: "vercel_connect", credentialRefs: [], credentialSecretRefs: [], externalCredential: { provider: "vercel_connect", connectorId: "scl_posthog", connectorUid: "posthog-paperclip", service: "mcp.posthog.com/mcp", principalMode: "app", }, }); expect(observedAuthorization).toContain("Bearer posthog-provider-bearer"); expect(storedSecrets).toEqual([]); expect(storedGrant?.externalCredential).toMatchObject({ tokenId: "stk_posthog", subjectType: "app", }); const durableAndApiState = JSON.stringify({ storedConnection, storedGrant, connected, }); expect(durableAndApiState).not.toContain("posthog-provider-bearer"); expect(durableAndApiState).not.toContain("must-not-persist@example.com"); expect(durableAndApiState).not.toContain("vercel-bootstrap-authority"); const otherCompany = await createCompany(db); await expect( service.connectGalleryApp( otherCompany.id, { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", credentialSource: "vercel_connect", vercelConnect: { connector: "posthog-paperclip" }, configValues: { projectId: "67890", mode: "tools" }, }, { actorType: "user", actorId: "other-board" }, ), ).rejects.toMatchObject({ status: 409, details: { code: "vercel_connect_app_connector_in_use" }, }); const removed = await service.archiveConnection( connected.connectionId, company.id, { actorType: "user", actorId: "board" }, ); expect(removed.connection).toMatchObject({ status: "archived", enabled: false, }); expect(removed.removal.externalCredentialCleanup).toMatchObject({ provider: "vercel_connect", appSubjectCleanup: "manage_in_vercel", userSubjectsAttempted: 0, }); expect(revoke).not.toHaveBeenCalled(); }); it("rejects an attached Vercel connector for the wrong reviewed service", async () => { vi.stubEnv("PAPERCLIP_VERCEL_CONNECT_ENABLED", "true"); vi.stubEnv( "PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN", "vercel-bootstrap-authority", ); const company = await createCompany(db); const service = createTestToolAccessService(db, { vercelConnectClient: { getConnectorMetadata: vi.fn(async () => ({ id: "scl_linear", uid: "linear-paperclip", name: "Linear", type: "api-key", service: "linear", createdAt: Date.now(), updatedAt: Date.now(), vendor: {}, })), getToken: vi.fn(), startAuthorization: vi.fn(), revoke: vi.fn(), evict: vi.fn(), }, }); await expect( service.connectGalleryApp( company.id, { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", credentialSource: "vercel_connect", vercelConnect: { connector: "linear-paperclip" }, configValues: { projectId: "12345", mode: "tools" }, }, { actorType: "user", actorId: "board" }, ), ).rejects.toMatchObject({ status: 400, details: { code: "vercel_connect_service_mismatch" }, }); }); it("binds the Vercel OAuth callback to one company, actor, session, and one-time state", async () => { vi.stubEnv("PAPERCLIP_VERCEL_CONNECT_ENABLED", "true"); vi.stubEnv( "PAPERCLIP_VERCEL_CONNECT_ACCESS_TOKEN", "vercel-bootstrap-authority", ); const company = await createCompany(db); await grantBoardUser(db, company.id, "board-user", [], "operator"); const getToken = vi.fn(async () => ({ token: "notion-provider-bearer", tokenId: "stk_notion", expiresAt: Date.now() + 60_000, connector: { id: "scl_notion", uid: "notion-paperclip", type: "oauth" }, tenantId: "notion-workspace", })); const startAuthorization = vi.fn( async () => ({ request: "authorization-request", verifier: "verifier-owned-by-vercel", url: "https://vercel.com/connect/authorize/request-1", expiresAt: Date.now() + 5 * 60_000, }), ); const revoke = vi.fn(); const service = createTestToolAccessService(db, { vercelConnectClient: { getConnectorMetadata: vi.fn(async () => ({ id: "scl_notion", uid: "notion-paperclip", name: "Paperclip Notion", type: "oauth", service: "notion", createdAt: Date.now(), updatedAt: Date.now(), vendor: {}, })), getToken, startAuthorization, revoke, evict: vi.fn(), }, remoteHttpRequest: async () => mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "search_pages", annotations: { readOnlyHint: true } }, ], }, }), }); const connected = await service.connectGalleryApp( company.id, { galleryKey: "notion", credentialSource: "vercel_connect", vercelConnect: { connector: "notion-paperclip" }, grantKind: "user", }, { actorType: "user", actorId: "board-user", sessionId: "board-session" }, ); const actor = { actorType: "user" as const, actorId: "board-user", sessionId: "board-session", }; const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "http://127.0.0.1:3100/api/tools/oauth/callback", actor, }, ); const [stateRow] = await db .select() .from(toolOauthStates) .where(eq(toolOauthStates.connectionId, connected.connectionId)); expect(started.authorizationUrl).toBe( "https://vercel.com/connect/authorize/request-1", ); expect(startAuthorization).toHaveBeenCalledWith( expect.objectContaining({ connector: "notion-paperclip", subject: expect.objectContaining({ type: "user" }), resources: ["https://mcp.notion.com/mcp"], }), expect.stringMatching( /^http:\/\/localhost:3100\/api\/tools\/vercel-connect\/callback\?state=/, ), ); expect(stateRow).toMatchObject({ companyId: company.id, createdByActorType: "user", createdByActorId: "board-user", createdBySessionId: "board-session", codeVerifier: "vercel-connect", }); await expect( service.completeVercelConnectCallback({ state: stateRow!.state, actor: { actorType: "user", actorId: "other-user", sessionId: "other-session", }, }), ).rejects.toMatchObject({ status: 403 }); await db .update(companyMemberships) .set({ membershipRole: "viewer" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "board-user"), ), ); await expect( service.completeVercelConnectCallback({ state: stateRow!.state, actor }), ).rejects.toMatchObject({ status: 403 }); expect(getToken).not.toHaveBeenCalled(); await db .update(companyMemberships) .set({ membershipRole: "operator" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "board-user"), ), ); await service.startOAuth(company.id, connected.connectionId, { redirectUri: "http://127.0.0.1:3100/api/tools/oauth/callback", actor, }); const [restartedStateRow] = await db .select() .from(toolOauthStates) .where(eq(toolOauthStates.connectionId, connected.connectionId)); expect(restartedStateRow?.state).toBeTruthy(); const completed = await service.completeVercelConnectCallback({ state: restartedStateRow!.state, actor, }); expect(completed.connection).toMatchObject({ status: "active", credentialSource: "vercel_connect", }); expect(getToken).toHaveBeenCalledWith(expect.any(Object), { forceRefresh: true, }); expect( await db .select() .from(toolOauthStates) .where(eq(toolOauthStates.state, stateRow!.state)), ).toEqual([]); const grants = await service.listConnectionGrants( completed.connectionId, company.id, ); expect(grants.grants[0]?.externalCredential).toMatchObject({ provider: "vercel_connect", subjectType: "user", tokenId: "stk_notion", }); expect(JSON.stringify(grants)).not.toContain("subjectId"); expect(JSON.stringify({ completed, grants })).not.toContain( "notion-provider-bearer", ); const revoked = await service.revokeConnectionGrant( completed.connectionId, grants.grants[0]!.id, actor, ); expect(revoked.status).toBe("revoked"); expect(revoke).toHaveBeenCalledTimes(1); await expect( service.completeVercelConnectCallback({ state: restartedStateRow!.state, actor, }), ).rejects.toMatchObject({ status: 400 }); }); it("resumes an interrupted configured OAuth draft instead of conflicting on its generated name", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const name = "Supabase for the company"; const first = await service.connectGalleryApp( company.id, { galleryKey: "supabase", connectionMethodKey: "mcp-oauth", name, configValues: { projectRef: "firstprojectref12345", readOnly: true, }, }, { actorType: "user", actorId: "board" }, ); const resumed = await service.connectGalleryApp( company.id, { galleryKey: "supabase", connectionMethodKey: "mcp-oauth", name, configValues: { projectRef: "secondprojectref1234", readOnly: true, features: "database", }, }, { actorType: "user", actorId: "board" }, ); expect(resumed.connectionId).toBe(first.connectionId); expect(resumed.application.id).toBe(first.application.id); expect(resumed.connection).toMatchObject({ status: "draft", enabled: false, config: { url: "https://mcp.supabase.com/mcp?project_ref=secondprojectref1234&read_only=true&features=database", sourceTemplateKey: "supabase", connectionMethodKey: "mcp-oauth", methodConfig: { projectRef: "secondprojectref1234", readOnly: true, features: "database", }, }, }); await expect( db .select() .from(toolApplications) .where(eq(toolApplications.companyId, company.id)), ).resolves.toHaveLength(1); await expect( db .select() .from(toolConnections) .where(eq(toolConnections.companyId, company.id)), ).resolves.toHaveLength(1); }); it("resumes an explicitly selected draft even when its application is already active", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const first = await service.connectGalleryApp( company.id, { galleryKey: "supabase", connectionMethodKey: "mcp-oauth", name: "Supabase for the company", configValues: { projectRef: "firstprojectref12345", readOnly: true, }, }, { actorType: "user", actorId: "board" }, ); await db .update(toolApplications) .set({ status: "active" }) .where(eq(toolApplications.id, first.application.id)); const resumed = await service.connectGalleryApp( company.id, { galleryKey: "supabase", connectionMethodKey: "mcp-oauth", resumeConnectionId: first.connectionId, name: "Supabase for the company", configValues: { projectRef: "secondprojectref1234", readOnly: true, }, }, { actorType: "user", actorId: "board" }, ); expect(resumed.connectionId).toBe(first.connectionId); expect(resumed.application.id).toBe(first.application.id); expect(resumed.connection.config).toMatchObject({ sourceTemplateKey: "supabase", connectionMethodKey: "mcp-oauth", methodConfig: { projectRef: "secondprojectref1234", readOnly: true, }, }); await expect( db .select() .from(toolApplications) .where(eq(toolApplications.companyId, company.id)), ).resolves.toHaveLength(1); await expect( db .select() .from(toolConnections) .where(eq(toolConnections.companyId, company.id)), ).resolves.toHaveLength(1); }); it("reconnects an exact active custom MCP connection without duplicating its identity", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "archive_read", annotations: { readOnlyHint: true } }, ]); const first = await service.connectGalleryApp( company.id, { link: "https://fixture.example/mcp", authMode: "none", name: "Archive", }, { actorType: "user", actorId: "board" }, ); await db .update(toolConnections) .set({ status: "active", healthStatus: "error" }) .where(eq(toolConnections.id, first.connectionId)); mockToolsList([ { name: "archive_read", annotations: { readOnlyHint: true } }, ]); const reconnected = await service.connectGalleryApp( company.id, { link: "https://fixture.example/mcp", authMode: "none", reconnectConnectionId: first.connectionId, }, { actorType: "user", actorId: "board" }, ); expect(reconnected.connectionId).toBe(first.connectionId); const originalAgent = await createAgent(db, company.id); const requester = await createAgent(db, company.id); const ids = reconnected.actions.readOnly.map( (action) => action.catalogEntryId, ); await service.finishGalleryAppConnection(company.id, first.connectionId, { enabledCatalogEntryIds: ids, askFirstCatalogEntryIds: [], access: { agentIds: [originalAgent.id] }, }); const additive = await service.finishGalleryAppConnection( company.id, first.connectionId, { enabledCatalogEntryIds: ids, askFirstCatalogEntryIds: [], access: { agentIds: [requester.id] }, preserveExistingAccess: true, }, ); expect(additive.profileBindings.map((binding) => binding.targetId)).toEqual( expect.arrayContaining([originalAgent.id, requester.id]), ); expect( await db .select() .from(toolConnections) .where(eq(toolConnections.companyId, company.id)), ).toHaveLength(1); await expect( service.connectGalleryApp( company.id, { galleryKey: "notion", reconnectConnectionId: first.connectionId }, { actorType: "user", actorId: "board" }, ), ).rejects.toThrow("preserve the configured provider"); const other = await createCompany(db); await expect( service.connectGalleryApp( other.id, { link: "https://fixture.example/mcp", reconnectConnectionId: first.connectionId, }, { actorType: "user", actorId: "board" }, ), ).rejects.toThrow("not found"); }); it("refuses a personal identity when no named user is making the request", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "query_insight", annotations: { readOnlyHint: true } }, ]); await expect( service.connectGalleryApp( company.id, { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", credentialValues: { "credentials.authorization": "phx_agent-secret" }, configValues: { projectId: "12345", mode: "tools" }, grantKind: "user", }, { actorType: "agent", actorId: "agent-1" }, ), ).rejects.toMatchObject({ status: 400 }); }); it("requires an explicit PostHog method and projects optional validated project filters", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); await expect( service.connectGalleryApp( company.id, { galleryKey: "posthog", configValues: { projectId: "12345", features: "insights" }, }, { actorType: "user", actorId: "board" }, ), ).rejects.toMatchObject({ status: 400 }); const fetchMock = mockToolsList([ { name: "query_insight", annotations: { readOnlyHint: true } }, { name: "delete_feature_flag" }, { name: "brand_new_tool" }, ]); const result = await service.connectGalleryApp( company.id, { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", credentialValues: { "credentials.authorization": "phx_test-secret" }, configValues: { projectId: "12345", readOnly: true, features: "insights, error_tracking\ninsights", tools: "query_insight", mode: "tools", }, }, { actorType: "user", actorId: "board" }, ); expect(fetchMock).toHaveBeenCalledWith( "https://mcp.posthog.com/mcp?readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer phx_test-secret", "x-posthog-project-id": "12345", }), }), ); expect(result.connection).toMatchObject({ authKind: "api_key", config: { sourceTemplateKey: "posthog", connectionMethodKey: "mcp-api-key", methodConfig: { projectId: "12345", readOnly: true, features: "insights,error_tracking", tools: "query_insight", mode: "tools", }, safeDefault: true, }, }); expect(JSON.stringify(result.connection.config)).not.toContain( "phx_test-secret", ); expect(result.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "query_insight", riskLevel: "read", status: "active", }), expect.objectContaining({ toolName: "delete_feature_flag", riskLevel: "destructive", status: "active", }), expect.objectContaining({ toolName: "brand_new_tool", riskLevel: "write", status: "active", }), ]), ); }); it("connects PostHog with provider defaults and no project pin", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ { name: "query_insight", annotations: { readOnlyHint: true } }, ]); const result = await service.connectGalleryApp( company.id, { galleryKey: "posthog", connectionMethodKey: "mcp-api-key", credentialValues: { "credentials.authorization": "phx_test-secret" }, }, { actorType: "user", actorId: "board" }, ); expect(fetchMock).toHaveBeenCalledWith( "https://mcp.posthog.com/mcp?mode=tools", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer phx_test-secret", }), }), ); expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toHaveProperty( "x-posthog-project-id", ); expect(result.connection.config).toMatchObject({ methodConfig: { readOnly: false, mode: "tools" }, }); }); it("stores approved class-3 credential refs on thin tool connections", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const [secret] = await db .insert(companySecrets) .values({ companyId: company.id, key: `discord.bot_token.${randomUUID()}`, name: `Discord bot token ${randomUUID()}`, provider: "local_encrypted", }) .returning(); const connection = await service.createConnection(company.id, { applicationName: "Discord", name: "Discord bot token", transport: "mcp_remote", config: { url: "https://discord.example.test/mcp" }, enabled: false, status: "draft", credentialSecretRefs: [ { secretId: secret!.id, versionSelector: "latest", configPath: "credentials.bot_token", label: "Discord bot token", projectionClass: "class_3_static_lease", projectionAllowlistKey: "discord.bot_token", }, ], }); expect(connection.credentialSecretRefs).toEqual([ expect.objectContaining({ secretId: secret!.id, configPath: "credentials.bot_token", projectionClass: "class_3_static_lease", projectionAllowlistKey: "discord.bot_token", }), ]); const bindings = await db .select() .from(companySecretBindings) .where( and( eq(companySecretBindings.companyId, company.id), eq(companySecretBindings.targetId, connection.id), ), ); expect(bindings).toEqual([ expect.objectContaining({ secretId: secret!.id, targetType: "tool_connection", configPath: "credentials.bot_token", projectionClass: "class_3_static_lease", projectionAllowlistKey: "discord.bot_token", }), ]); }); it("rejects class-3 tool connection refs outside the enumerated allowlist", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, applicationKey: `blocked-${randomUUID()}`, name: `Blocked App ${randomUUID()}`, type: "mcp_http", status: "active", }) .returning(); const [secret] = await db .insert(companySecrets) .values({ companyId: company.id, key: `github.token.${randomUUID()}`, name: `GitHub token ${randomUUID()}`, provider: "local_encrypted", }) .returning(); await expect( service.createConnection(company.id, { applicationId: application!.id, name: "Blocked class-3 token", transport: "mcp_remote", config: { url: "https://blocked.example.test/mcp" }, enabled: false, status: "draft", credentialSecretRefs: [ { secretId: secret!.id, versionSelector: "latest", configPath: "credentials.bot_token", label: "GitHub token", projectionClass: "class_3_static_lease", projectionAllowlistKey: "github.token", }, ], }), ).rejects.toMatchObject({ status: 422, details: { code: "class_3_static_lease_not_allowed" }, }); await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); await expect(db.select().from(companySecretBindings)).resolves.toHaveLength( 0, ); }); it("rejects Google Sheets gallery connects that claim a spreadsheet bound to another company", async () => { const companyA = await createCompany(db); const companyB = await createCompany(db); const service = createTestToolAccessService(db); vi.stubEnv( "GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON", JSON.stringify({ client_email: "robot@example.iam.gserviceaccount.com", }), ); await service.connectGalleryApp( companyB.id, { galleryKey: "google-sheets", connectionMethodKey: "local", name: "Company B sheets", configValues: { allowedSpreadsheetIds: ["shared-sheet"] }, }, { actorType: "user", actorId: "board-b" }, ); await expect( service.connectGalleryApp( companyA.id, { galleryKey: "google-sheets", connectionMethodKey: "local", name: "Company A sheets", configValues: { allowedSpreadsheetIds: ["shared-sheet"] }, }, { actorType: "user", actorId: "board-a" }, ), ).rejects.toMatchObject({ status: 409, details: { code: "google_sheets_spreadsheet_already_bound", spreadsheetIds: ["shared-sheet"], }, }); await expect(db.select().from(toolConnections)).resolves.toHaveLength(1); }); it("stores Google Sheets catalog input schemas from the approved stdio template", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); vi.stubEnv( "GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON", JSON.stringify({ client_email: "robot@example.iam.gserviceaccount.com", }), ); const connect = await service.connectGalleryApp( company.id, { galleryKey: "google-sheets", connectionMethodKey: "local", name: "Company sheets", configValues: { allowedSpreadsheetIds: ["sheet-with-inputs"] }, }, { actorType: "user", actorId: "board" }, ); const descriptions = Object.fromEntries( connect.catalog.map((entry) => [entry.toolName, entry.description]), ); expect(descriptions).toMatchObject({ list_spreadsheets: "List the Google Sheets spreadsheets configured in this connection allowlist.", get_spreadsheet_info: "Get spreadsheet metadata and sheet tab information for an allowlisted spreadsheet.", read_values: "Read cell values from an allowlisted spreadsheet range.", search_rows: "Search rows in an allowlisted spreadsheet range.", append_rows: "Append rows to an allowlisted spreadsheet range.", update_values: "Update values in an allowlisted spreadsheet range.", add_sheet_tab: "Add a sheet tab to an allowlisted spreadsheet.", clear_values: "Clear values in an allowlisted spreadsheet range.", delete_rows: "Delete rows from an allowlisted spreadsheet tab.", }); expect( connect.catalog.find((entry) => entry.toolName === "read_values") ?.inputSchema, ).toMatchObject({ type: "object", properties: { spreadsheetId: expect.objectContaining({ type: "string" }), range: expect.objectContaining({ type: "string" }), }, required: ["spreadsheetId", "range"], }); expect( connect.catalog.find((entry) => entry.toolName === "append_rows") ?.inputSchema, ).toMatchObject({ properties: { spreadsheetId: expect.objectContaining({ type: "string" }), range: expect.objectContaining({ type: "string" }), values: expect.objectContaining({ type: "array" }), valueInputOption: expect.objectContaining({ enum: ["RAW", "USER_ENTERED"], }), }, required: ["spreadsheetId", "range", "values"], }); expect( connect.catalog.find((entry) => entry.toolName === "delete_rows") ?.inputSchema, ).toMatchObject({ properties: { spreadsheetId: expect.objectContaining({ type: "string" }), sheetId: expect.objectContaining({ type: "integer" }), startIndex: expect.objectContaining({ type: "integer" }), endIndex: expect.objectContaining({ type: "integer" }), }, required: ["spreadsheetId", "sheetId", "startIndex", "endIndex"], }); await db .update(toolCatalogEntries) .set({ inputSchema: { type: "object", properties: {} } }) .where( eq( toolCatalogEntries.id, connect.catalog.find((entry) => entry.toolName === "read_values")!.id, ), ); expect( (await service.listCatalog(connect.connectionId)).find( (entry) => entry.toolName === "read_values", )?.inputSchema, ).toMatchObject({ properties: { spreadsheetId: expect.objectContaining({ type: "string" }), range: expect.objectContaining({ type: "string" }), }, required: ["spreadsheetId", "range"], }); }); it("rejects raw Google Sheets connection patches that claim another company's spreadsheet", async () => { const companyA = await createCompany(db); const companyB = await createCompany(db); const service = createTestToolAccessService(db); const app = createRouteApp(db); vi.stubEnv( "GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON", JSON.stringify({ client_email: "robot@example.iam.gserviceaccount.com", }), ); await service.connectGalleryApp( companyB.id, { galleryKey: "google-sheets", connectionMethodKey: "local", name: "Company B sheets", configValues: { allowedSpreadsheetIds: ["company-b-sheet"] }, }, { actorType: "user", actorId: "board-b" }, ); const companyAConnection = await service.connectGalleryApp( companyA.id, { galleryKey: "google-sheets", connectionMethodKey: "local", name: "Company A sheets", configValues: { allowedSpreadsheetIds: ["company-a-sheet"] }, }, { actorType: "user", actorId: "board-a" }, ); const res = await request(app) .patch(`/api/tool-connections/${companyAConnection.connectionId}`) .send({ config: { templateId: "paperclip.google-sheets", sourceTemplateKey: "google-sheets", allowedSpreadsheetIds: ["company-b-sheet"], env: { GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "company-b-sheet" }, }, }); expect(res.status).toBe(409); expect(res.body).toMatchObject({ error: "Google Sheets spreadsheet is already connected to another company.", details: { code: "google_sheets_spreadsheet_already_bound", spreadsheetIds: ["company-b-sheet"], }, }); const [stillCompanyA] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, companyAConnection.connectionId)); expect(stillCompanyA.config.allowedSpreadsheetIds).toEqual([ "company-a-sheet", ]); expect(stillCompanyA.config.env).toMatchObject({ GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "company-a-sheet", }); }); it("tags a pause PATCH with a lifecycle activity row the Activity tab can surface", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const app = createRouteApp(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "Google Sheets", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "Sheets", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://sheets.example/mcp" }, transportConfig: { url: "https://sheets.example/mcp" }, }) .returning(); const res = await request(app) .patch(`/api/tool-connections/${connection.id}`) .send({ enabled: false }); expect(res.status).toBe(200); const rows = await db .select() .from(activityLog) .where( and( eq(activityLog.companyId, company.id), eq(activityLog.entityId, connection.id), ), ); expect(rows).toHaveLength(1); expect(rows[0]?.details).toMatchObject({ lifecycle: "paused", enabled: false, }); const activity = await service.listConnectionActivity( connection.id, company.id, 20, ); expect(activity.lifecycleEvents.map((event) => event.type)).toEqual([ "app_paused", ]); }); it("preserves all active personal OAuth declarations through pause, resume, and metadata edits", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "GitHub", type: "mcp_http" }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "GitHub", uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true, credentialPolicy: "per_user", config: { url: "https://api.githubcopilot.com/mcp/", sourceTemplateKey: "github", }, }) .returning(); const [sharedDefinition] = await db .insert(userSecretDefinitions) .values({ companyId: company.id, key: randomUUID(), name: "OAuth access token", }) .returning(); const definitionIds = [sharedDefinition.id]; for (const user of ["A", "B", "revoked"]) { const definition = user === "revoked" ? ( await db .insert(userSecretDefinitions) .values({ companyId: company.id, key: randomUUID(), name: "Revoked identity", }) .returning() )[0] : sharedDefinition; const [secret] = await db .insert(companySecrets) .values({ companyId: company.id, key: randomUUID(), name: user, scope: "user", ownerUserId: user, userSecretDefinitionId: definition.id, }) .returning(); await db .insert(connectionGrants) .values({ companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId: user, status: user === "revoked" ? "revoked" : "active", credentialSecretRefs: [ { secretId: secret.id, configPath: "oauth.access_token", versionSelector: "latest", }, ], }); } for (const edit of [ { enabled: false }, { enabled: true }, { name: "Renamed GitHub" }, ]) { await service.updateConnection(connection.id, edit); const declarations = await db .select() .from(userSecretDeclarations) .where(eq(userSecretDeclarations.targetId, connection.id)); expect( declarations.map((row) => row.userSecretDefinitionId).sort(), ).toEqual([...definitionIds].sort()); expect( declarations.every((row) => row.configPath === "oauth.access_token"), ).toBe(true); } }); it("allows same-company Google Sheets updates and derives the env mirror from the allowlist", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); vi.stubEnv( "GOOGLE_SHEETS_SERVICE_ACCOUNT_JSON", JSON.stringify({ client_email: "robot@example.iam.gserviceaccount.com", }), ); const first = await service.connectGalleryApp( company.id, { galleryKey: "google-sheets", connectionMethodKey: "local", name: "First sheets", configValues: { allowedSpreadsheetIds: ["same-company-sheet"] }, }, { actorType: "user", actorId: "board" }, ); const second = await service.connectGalleryApp( company.id, { galleryKey: "google-sheets", connectionMethodKey: "local", name: "Second sheets", configValues: { allowedSpreadsheetIds: ["same-company-sheet"] }, }, { actorType: "user", actorId: "board" }, ); const updated = await service.updateConnection(second.connectionId, { config: { templateId: "paperclip.google-sheets", sourceTemplateKey: "google-sheets", allowedSpreadsheetIds: [ "same-company-sheet", "new-company-sheet", "same-company-sheet", ], env: { GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "attacker-controlled-sheet", EXTRA_ENV: "preserved", }, }, }); expect(first.connection.config.allowedSpreadsheetIds).toEqual([ "same-company-sheet", ]); expect(updated.config.allowedSpreadsheetIds).toEqual([ "same-company-sheet", "new-company-sheet", ]); expect(updated.config.env).toEqual({ EXTRA_ENV: "preserved", GOOGLE_SHEETS_ALLOWED_SPREADSHEET_IDS: "same-company-sheet,new-company-sheet", }); expect(updated.transportConfig).toEqual(updated.config); }); it.each(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS.flatMap((profile) => [ ["local_trusted", "private", "http://127.0.0.1:3102"] as const, ["authenticated", "public", "https://tenant.paperclip.app"] as const, ].map(([deploymentMode, deploymentExposure, origin]) => ({ profile, deploymentMode, deploymentExposure, origin }))))( "connects advertised Workspace $profile without mutating definitions in $deploymentMode", async ({ profile, deploymentMode, deploymentExposure, origin }) => { const slug = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].appSlug; const methodKey = getConnectableAppDefinition(slug)!.methods.find((method) => method.connectorProfile === profile)!.key; const company = await createCompany(db); const userId = "board-user"; await grantBoardUser(db, company.id, userId, [], "owner"); const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile); const definitionBefore = JSON.stringify(getConnectableAppDefinition(slug)); const app = createRouteApp(db, deploymentMode === "authenticated" ? boardSessionActor(company.id, "owner", userId) : undefined, undefined, { deploymentMode, deploymentExposure, paperclipCloudConnector: connector }); const gallery = await request(app).get(`/api/companies/${company.id}/tools/gallery`); const workspaceApp = gallery.body.apps.find((entry: { slug: string }) => entry.slug === slug); expect(workspaceApp.methods.map((method: { key: string }) => method.key)).toContain(methodKey); const connected = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({ galleryKey: slug, connectionMethodKey: methodKey, grantKind: "user", name: `Personal ${slug}`, }); expect(connected.status).toBe(201); expect(connected.body.connection).toMatchObject({ credentialPolicy: "per_user", ownership: "platform_shared" }); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); const actor = { actorType: "user" as const, actorId: userId }; const started = await service.startOAuth(company.id, connected.body.connectionId, { redirectUri: `${origin}/api/tools/oauth/cloud-connector/callback`, actor, }); expect(connector.startAuthorization).toHaveBeenCalledWith(expect.objectContaining({ profile, companyId: company.id, subject: userId, returnUri: `${origin}/api/tools/oauth/cloud-connector/callback`, })); mockToolsList([]); const completed = await service.completePaperclipCloudConnectorCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: `${profile}-claim`, actor, }); expect(completed.connection).toMatchObject({ status: "active", credentialPolicy: "per_user" }); expect(JSON.stringify(getConnectableAppDefinition(slug))).toBe(definitionBefore); }); it.each(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS)("connects advertised Workspace %s with a Cloud-delivered environment identity", async (profile) => { const slug = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].appSlug; const methodKey = getConnectableAppDefinition(slug)!.methods.find((method) => method.connectorProfile === profile)!.key; const company = await createCompany(db); const userId = `cloud-workspace-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const signing = generateKeyPairSync("ed25519"); const sealing = generateKeyPairSync("x25519"); vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://tenant.paperclip.app"); vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_BASE_URL", "https://my.paperclip.app"); vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT", "production"); vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_INSTANCE_ID", "inst-cloud-workspace-regression"); vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_SIGN_PRIVATE_KEY", signing.privateKey.export({ type: "pkcs8", format: "pem" }).toString()); vi.stubEnv("PAPERCLIP_CLOUD_CONNECTOR_SEAL_PRIVATE_KEY", sealing.privateKey.export({ type: "pkcs8", format: "pem" }).toString()); invalidatePaperclipCloudConnectorCapabilities(); const cloudRequest = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const signed = JSON.parse(String(init?.body)).request as string; const claims = JSON.parse(Buffer.from(signed.split(".")[1]!, "base64url").toString()); expect(claims).toMatchObject({ iss: "inst-cloud-workspace-regression", env: "production" }); if (String(url) === "https://my.paperclip.app/v1/connector/instance-status") { expect(claims.op).toBe("status"); return Response.json({ active: true, status: "active", profiles: [profile] }); } expect(String(url)).toBe("https://my.paperclip.app/v1/connector/sessions"); expect(claims).toMatchObject({ op: "session", prf: profile, cid: company.id, sub: userId, ruri: "https://tenant.paperclip.app/api/tools/oauth/cloud-connector/callback", }); return Response.json({ confirmationUrl: "https://my.paperclip.app/connections/confirm?id=test-workspace-session", expiresAt: new Date(Date.now() + 600_000).toISOString(), }); }); try { const app = createRouteApp(db, boardSessionActor(company.id, "owner", userId), undefined, { deploymentMode: "authenticated", deploymentExposure: "public", }); const gallery = await request(app).get(`/api/companies/${company.id}/tools/gallery`); expect(gallery.body.apps.find((entry: { slug: string }) => entry.slug === slug).methods .map((method: { key: string }) => method.key)).toContain(methodKey); const result = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({ galleryKey: slug, connectionMethodKey: methodKey, grantKind: "user", name: `Cloud ${slug}`, }); expect(result.status, JSON.stringify(result.body)).toBe(201); expect(result.body.auth.startUrl).toBe("https://my.paperclip.app/connections/confirm?id=test-workspace-session"); expect(result.body.connection).toMatchObject({ credentialPolicy: "per_user", ownership: "platform_shared" }); expect(cloudRequest).toHaveBeenCalled(); } finally { invalidatePaperclipCloudConnectorCapabilities(); } }); it.each(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS.flatMap((profile) => [false, true].map((advertiseOther) => ({ profile, advertiseOther })), ))("rejects unavailable Workspace $profile (other profile advertised: $advertiseOther)", async ({ profile, advertiseOther }) => { const slug = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].appSlug; const methodKey = getConnectableAppDefinition(slug)!.methods.find((method) => method.connectorProfile === profile)!.key; const company = await createCompany(db); const connector = fakeGmailConnector(company.id, "board-user"); connector.getCapabilities = vi.fn(async (): Promise => advertiseOther ? [profile === "gmail.read" ? "drive.read" : "gmail.read"] : [], ); const app = createRouteApp(db, undefined, undefined, { paperclipCloudConnector: connector }); const response = await request(app).post(`/api/companies/${company.id}/tools/apps/connect`).send({ galleryKey: slug, connectionMethodKey: methodKey, name: `Unavailable ${slug}`, }); expect(response.status).toBe(422); expect(connector.startAuthorization).not.toHaveBeenCalled(); expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toEqual([]); }); it("completes brokered Gmail OAuth with a single database connection", async () => { const company = await createCompany(db); const userId = `gmail-member-${randomUUID()}`; await grantBoardUser(db, company.id, userId, []); const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1, }); const connector = fakeGmailConnector(company.id, userId); const service = createTestToolAccessService(callbackDb, { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; let deadline: ReturnType | null = null; mockToolsList([]); try { await callbackDb.execute(sql`select pg_backend_pid()`); const connected = await service.connectGalleryApp( company.id, { galleryKey: "gmail", connectionMethodKey: "paperclip-draft", grantKind: "user", name: "Gmail single-pool callback", }, actor, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const state = new URL(started.authorizationUrl).searchParams.get( "state", )!; const completed = await Promise.race([ service.completePaperclipCloudConnectorCallback({ state, claimId: "gmail-claim", actor, }), new Promise((_resolve, reject) => { deadline = setTimeout(() => { void callbackDb.$client .end({ timeout: 0 }) .finally(() => reject( new Error( "Gmail OAuth callback self-deadlocked with maxConnections=1", ), ), ); }, 5_000); }), ]); expect(completed.connection).toMatchObject({ status: "active", enabled: true, }); const [grant] = await callbackDb .select() .from(connectionGrants) .where( and( eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, userId), ), ); expect(grant).toMatchObject({ status: "active" }); expect( grant!.credentialSecretRefs.map((ref) => ref.configPath).sort(), ).toEqual(["oauth.access_token", "oauth.refresh_token"]); await expect( service.revokeConnectionGrant(connected.connectionId, grant!.id, actor), ).resolves.toMatchObject({ status: "revoked" }); expect(connector.revoke).not.toHaveBeenCalled(); } finally { if (deadline) clearTimeout(deadline); await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined); } }, 15_000); it("reports GitHub reauthorization for the viewer without borrowing another user's grant", async () => { const company = await createCompany(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "GitHub authorization fixture", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application!.id, name: "GitHub authorization fixture", uid: randomUUID(), transport: "mcp_remote", status: "active", enabled: true, credentialPolicy: "per_user", createdByUserId: "A", config: { sourceTemplateKey: "github" }, }) .returning(); await db .insert(connectionGrants) .values( ["A", "B"].map((user) => ({ companyId: company.id, connectionId: connection!.id, kind: "user" as const, subjectUserId: user, status: user === "A" ? ("revoked" as const) : ("active" as const), credentialSecretRefs: [], })), ); const service = createTestToolAccessService(db); expect( (await service.getConnection(connection!.id, company.id, "A")) .requiresReauthorization, ).toBe(true); expect( (await service.getConnection(connection!.id, company.id, "B")) .requiresReauthorization, ).toBe(false); expect( (await service.listConnections(company.id, "A"))[0] ?.requiresReauthorization, ).toBe(true); expect( (await service.listConnections(company.id, "B"))[0] ?.requiresReauthorization, ).toBe(false); }); it.each(["none", "event", "same-time-refresh", "one-conflict"])( "binds a managed GitHub identity and protects refresh from concurrent access changes (%s)", async (concurrentChange) => { const company = await createCompany(db); const userId = `github-manager-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const agent = await createAgent(db, company.id); const connector = fakeGitHubConnector(company.id, `agent:${agent.id}`); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; const githubDefinition = getConnectableAppDefinition("github")!; const previousOwnershipAvailability = githubDefinition.ownershipAvailability; githubDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true, }; let beforeRepositoryResponse = async () => {}; vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { const href = String(url); if (href === "https://api.github.com/user") { return mcpHttpResponse({ id: 42, login: "octocat", avatar_url: "https://avatars.example/octocat", }); } if (href.includes("https://api.github.com/user/installations?")) { return mcpHttpResponse({ installations: [ { id: 101, repository_selection: "selected", html_url: "https://github.com/settings/installations/101", account: { login: "paperclipai" }, }, ], }); } if ( href.includes( "https://api.github.com/user/installations/101/repositories?", ) ) { await beforeRepositoryResponse(); return mcpHttpResponse({ total_count: 3, repositories: [1, 2, 3].map((id) => ({ id, full_name: `paperclipai/repo-${id}`, description: "do-not-store", })), }); } if (href === GITHUB_CONNECTOR_PROFILES["github.code"].serverUrl) { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "get_pull_request", annotations: { readOnlyHint: true }, }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); try { const connected = await service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "managed", grantKind: "agent", subjectAgentId: agent.id, name: "Agent GitHub", }, actor, ); expect(connected.connection.credentialPolicy).toBe("per_agent"); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, subjectAgentId: agent.id, }, ); const state = new URL(started.authorizationUrl).searchParams.get( "state", )!; await db .update(companyMemberships) .set({ membershipRole: "operator" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, userId), ), ); await expect( service.completePaperclipCloudConnectorCallback({ state, claimId: "github-agent-claim", actor, }), ).rejects.toMatchObject({ status: 403 }); await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "user", principalId: userId, permissionKey: "tools:manage_connections", scope: null, grantedByUserId: "owner", }); const completed = await service.completePaperclipCloudConnectorCallback( { state, claimId: "github-agent-claim", actor, }, ); expect(completed.connection).toMatchObject({ credentialPolicy: "per_agent", status: "active", enabled: true, }); const [grant] = await db .select() .from(connectionGrants) .where( and( eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.kind, "agent"), eq(connectionGrants.subjectAgentId, agent.id), ), ); expect(grant).toMatchObject({ status: "active", subjectUserId: null, isDefault: false, providerTenant: { name: "octocat", oauth: { strategy: "paperclip_cloud_connector", accessTokenExpiresAt: null, }, github: { userId: "42", login: "octocat", installationCount: 1, repositoryCount: 3, repositorySelection: "selected", installationIds: ["101"], installationUrl: "https://github.com/apps/paperclip-development/installations/new", managementUrl: "https://github.com/settings/installations/101", appSlug: "paperclip-development", }, }, }); expect( grant!.credentialSecretRefs.map((ref) => ref.configPath), ).toEqual(["oauth.access_token"]); expect(JSON.stringify(grant)).not.toContain("do-not-store"); expect(connector.setWebhookBinding).toHaveBeenCalledWith( expect.objectContaining({ subject: `agent:${agent.id}`, companyId: company.id, connectionId: connected.connectionId, grantId: grant!.id, installationId: "101", active: true, }), ); await expect( db .select() .from(toolConnectionInstalls) .where( and( eq(toolConnectionInstalls.connectionId, connected.connectionId), eq(toolConnectionInstalls.targetType, "agent"), eq(toolConnectionInstalls.targetId, agent.id), ), ), ).resolves.toHaveLength(1); vi.mocked(connector.setWebhookBinding).mockClear(); if (concurrentChange !== "none") { beforeRepositoryResponse = async () => { if (concurrentChange === "one-conflict") beforeRepositoryResponse = async () => {}; const [latest] = await db .select() .from(connectionGrants) .where(eq(connectionGrants.id, grant!.id)); await db .update(connectionGrants) .set({ providerTenant: { ...latest!.providerTenant, github: { ...latest!.providerTenant!.github!, accessRevision: randomUUID(), // Simulate a refresh with identical timestamps, so only the unique // access revision can distinguish its newer access snapshot. ...(concurrentChange === "event" ? { lastWebhookAt: new Date().toISOString() } : {}), installationIds: [], installationCount: 0, repositoryCount: 0, repositorySelection: "none", repositories: undefined, webhookHealth: "unhealthy", }, }, }) .where(eq(connectionGrants.id, grant!.id)); }; if (concurrentChange === "one-conflict") { await expect( service.checkHealth(connected.connectionId, actor), ).resolves.toMatchObject({ connection: { healthStatus: "ok" } }); expect(connector.setWebhookBinding).toHaveBeenCalled(); const [latest] = await db .select() .from(connectionGrants) .where(eq(connectionGrants.id, grant!.id)); expect(latest?.status).toBe("active"); expect(latest?.providerTenant?.github?.repositoryCount).toBe(3); return; } await expect( service.checkHealth(connected.connectionId, actor), ).rejects.toThrow("GitHub access changed during refresh. Try again."); const [latest] = await db .select() .from(connectionGrants) .where(eq(connectionGrants.id, grant!.id)); expect(latest?.providerTenant?.github).toMatchObject({ installationIds: [], repositoryCount: 0, webhookHealth: "unhealthy", }); expect(latest?.providerTenant?.github?.repositories).toBeUndefined(); expect(connector.setWebhookBinding).not.toHaveBeenCalled(); } else { await expect( service.checkHealth(connected.connectionId, actor), ).resolves.toMatchObject({ connection: { healthStatus: "ok" } }); expect(connector.setWebhookBinding).toHaveBeenCalled(); } } finally { githubDefinition.ownershipAvailability = previousOwnershipAvailability; } }, ); it("replaces an archived dedicated GitHub identity with an explicitly selected personal identity", async () => { const company = await createCompany(db); const userId = `github-personal-revival-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const agent = await createAgent(db, company.id); const connector = fakeGitHubConnector(company.id, `agent:${agent.id}`); const originalClaim = connector.claim; connector.claim = vi.fn(async (input) => ({ ...(await originalClaim(input)), subject: input.subject, })); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; const githubDefinition = getConnectableAppDefinition("github")!; const previousOwnershipAvailability = githubDefinition.ownershipAvailability; githubDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true, }; vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { const href = String(url); if (href === "https://api.github.com/user") { return mcpHttpResponse({ id: 42, login: "octocat" }); } if (href.includes("https://api.github.com/user/installations?")) { return mcpHttpResponse({ installations: [ { id: 101, repository_selection: "selected", html_url: "https://github.com/settings/installations/101", account: { login: "paperclipai" }, }, ], }); } if ( href.includes( "https://api.github.com/user/installations/101/repositories?", ) ) { return mcpHttpResponse({ total_count: 1, repositories: [{ id: 1, full_name: "paperclipai/repo-1" }], }); } if (href === GITHUB_CONNECTOR_PROFILES["github.code"].serverUrl) { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "get_pull_request", annotations: { readOnlyHint: true } }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); try { const dedicated = await service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "managed", grantKind: "agent", subjectAgentId: agent.id, name: "GitHub", }, actor, ); const dedicatedStart = await service.startOAuth( company.id, dedicated.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, subjectAgentId: agent.id, }, ); await service.completePaperclipCloudConnectorCallback({ state: new URL(dedicatedStart.authorizationUrl).searchParams.get( "state", )!, claimId: "github-dedicated-before-removal", actor, }); await service.archiveConnection( dedicated.connectionId, company.id, actor, ); const personal = await service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "managed", grantKind: "user", name: "GitHub", }, actor, ); expect(personal.connectionId).toBe(dedicated.connectionId); expect(personal.connection).toMatchObject({ status: "draft", credentialPolicy: "per_user", }); const personalStart = await service.startOAuth( company.id, personal.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const completed = await service.completePaperclipCloudConnectorCallback({ state: new URL(personalStart.authorizationUrl).searchParams.get( "state", )!, claimId: "github-personal-after-removal", actor, }); expect(completed.connection).toMatchObject({ status: "active", credentialPolicy: "per_user", }); const grants = await service.listConnectionGrants( personal.connectionId, company.id, ); expect(grants.grants).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "agent", subjectAgentId: agent.id, status: "revoked", }), expect.objectContaining({ kind: "user", subjectUserId: userId, status: "active", }), ]), ); expect(grants.grants.some((grant) => grant.kind === "organization")).toBe( false, ); } finally { githubDefinition.ownershipAvailability = previousOwnershipAvailability; } }); it("routes a managed Drive callback into the personal vault, filtered catalog, and provider-specific activity", async () => { const company = await createCompany(db); const userId = `drive-member-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const profile = "drive.read" as const; const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile); connector.startAuthorization = vi.fn(async ({ returnState }) => ({ authorizationUrl: `https://my.example.test/connections/confirm?session=legacy&state=${encodeURIComponent(returnState)}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), handoff: { kind: "paperclip_cloud" as const, session: "cloud_session_abcdefghijklmnop", }, })); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; const driveDefinition = getConnectableAppDefinition("google-drive")!; const previousOwnershipAvailability = driveDefinition.ownershipAvailability; driveDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true, }; mockToolsList([ { name: "search_files", annotations: { readOnlyHint: true } }, { name: "create_file", annotations: { readOnlyHint: false } }, ]); try { const connected = await service.connectGalleryApp( company.id, { galleryKey: "google-drive", connectionMethodKey: "paperclip-read", grantKind: "user", name: "Drive managed read", }, actor, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const state = new URL(started.authorizationUrl).searchParams.get( "state", )!; expect(started.handoff).toEqual({ kind: "paperclip_cloud", session: "cloud_session_abcdefghijklmnop", }); const app = createRouteApp( db, boardSessionActor(company.id, "owner", userId), undefined, { paperclipCloudConnector: connector }, ); const callback = await request(app) .get("/api/tools/oauth/cloud-connector/callback") .query({ state, claim_id: "drive-claim" }) .set("accept", "application/json"); expect(callback.status).toBe(200); expect(connector.startAuthorization).toHaveBeenCalledWith( expect.objectContaining({ companyId: company.id, subject: userId, profile, }), ); expect(connector.claim).toHaveBeenCalledWith( expect.objectContaining({ companyId: company.id, subject: userId, profile, claimId: "drive-claim", redemptionId: state, }), ); expect(callback.body.connection).toMatchObject({ status: "active", enabled: true, healthStatus: "ok", config: { sourceTemplateKey: "google-drive", quarantineNewEntries: true, oauth: { strategy: "paperclip_cloud_connector", provider: "google-drive", connectorProfile: profile, resource: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].serverUrl, scopes: [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes], }, }, }); expect(callback.body.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "search_files", status: "active", riskLevel: "read", }), expect.objectContaining({ toolName: "create_file", status: "disabled", riskLevel: "write", }), ]), ); const [profileRow] = await db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); const profileEntries = await db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, profileRow!.id)); expect(profileEntries).toHaveLength(1); expect(profileEntries[0]).toMatchObject({ catalogEntryId: callback.body.catalog.find( (entry: { toolName: string }) => entry.toolName === "search_files", ).id, effect: "include", }); await expect( db .select() .from(toolConnectionInstalls) .where( and( eq(toolConnectionInstalls.connectionId, connected.connectionId), eq(toolConnectionInstalls.targetType, "company"), ), ), ).resolves.toHaveLength(1); const [grant] = await db .select() .from(connectionGrants) .where( and( eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.kind, "user"), eq(connectionGrants.subjectUserId, userId), ), ); expect(grant).toMatchObject({ status: "active", providerTenant: { name: "Google Drive", oauth: { strategy: "paperclip_cloud_connector", scopes: [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes], }, }, }); expect( grant!.credentialSecretRefs.map((ref) => ref.configPath).sort(), ).toEqual(["oauth.access_token", "oauth.refresh_token"]); const secrets = await db .select() .from(companySecrets) .where( inArray( companySecrets.id, grant!.credentialSecretRefs.map((ref) => ref.secretId), ), ); expect(secrets).toHaveLength(2); expect(secrets).toEqual( expect.arrayContaining([ expect.objectContaining({ companyId: company.id, scope: "user", ownerUserId: userId, provider: "local_encrypted", }), ]), ); const versions = await db .select() .from(companySecretVersions) .where( inArray( companySecretVersions.secretId, grant!.credentialSecretRefs.map((ref) => ref.secretId), ), ); expect(versions).toHaveLength(2); expect(JSON.stringify(versions)).not.toContain("drive-access-token"); expect(JSON.stringify(versions)).not.toContain("drive-refresh-token"); const [activity] = await db .select() .from(activityLog) .where( and( eq(activityLog.entityId, connected.connectionId), eq(activityLog.action, "tool_app.oauth_connected"), ), ); expect(activity?.details).toMatchObject({ applicationId: callback.body.application.id, catalogEntryCount: 2, provider: "google-drive", profile, }); expect(JSON.stringify(activity?.details)).not.toContain(userId); expect(JSON.stringify(activity?.details)).not.toContain("token"); } finally { driveDefinition.ownershipAvailability = previousOwnershipAvailability; } }); it("routes a managed Drive callback into the shared organization vault when selected", async () => { const company = await createCompany(db); const userId = `shared-drive-member-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const profile = "drive.read" as const; const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; const driveDefinition = getConnectableAppDefinition("google-drive")!; const previousOwnershipAvailability = driveDefinition.ownershipAvailability; driveDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true, }; mockToolsList([ { name: "search_files", annotations: { readOnlyHint: true } }, ]); try { const connected = await service.connectGalleryApp( company.id, { galleryKey: "google-drive", connectionMethodKey: "paperclip-read", grantKind: "organization", name: "Shared Drive managed read", }, actor, ); expect(connected.connection).toMatchObject({ credentialPolicy: "shared", }); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const completed = await service.completePaperclipCloudConnectorCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "shared-drive-claim", actor, }); expect(completed.connection).toMatchObject({ status: "active", enabled: true, credentialPolicy: "shared", config: { oauth: { strategy: "paperclip_cloud_connector", connectorSubjectUserId: userId, }, }, }); expect(completed.connection.credentialRefs).toEqual([ expect.objectContaining({ name: "oauth.access_token", placement: "header", key: "Authorization", prefix: "Bearer ", }), ]); expect( completed.connection.credentialSecretRefs .map((ref) => ref.configPath) .sort(), ).toEqual(["oauth.access_token", "oauth.refresh_token"]); const grants = await db .select() .from(connectionGrants) .where(eq(connectionGrants.connectionId, connected.connectionId)); expect(grants).toHaveLength(1); expect(grants[0]).toMatchObject({ kind: "organization", subjectUserId: null, isDefault: true, status: "active", providerTenant: { externalId: userId, oauth: { strategy: "paperclip_cloud_connector" }, }, }); expect( grants[0]!.credentialSecretRefs.map((ref) => ref.secretId).sort(), ).toEqual( completed.connection.credentialSecretRefs .map((ref) => ref.secretId) .sort(), ); const secrets = await db .select() .from(companySecrets) .where( inArray( companySecrets.id, completed.connection.credentialSecretRefs.map( (ref) => ref.secretId, ), ), ); expect(secrets).toHaveLength(2); expect( secrets.every( (secret) => secret.scope === "company" && secret.ownerUserId === null, ), ).toBe(true); const bindings = await db .select() .from(companySecretBindings) .where( and( eq(companySecretBindings.targetType, "tool_connection"), eq(companySecretBindings.targetId, connected.connectionId), ), ); expect(bindings.map((binding) => binding.configPath).sort()).toEqual([ "credentials.oauth.access_token", "oauth.access_token", "oauth.refresh_token", ]); } finally { driveDefinition.ownershipAvailability = previousOwnershipAvailability; } }); it("activates allowed Drive write actions without approval defaults after a managed callback", async () => { const company = await createCompany(db); const userId = `drive-write-member-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const profile = "drive.write" as const; const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; const driveDefinition = getConnectableAppDefinition("google-drive")!; const previousOwnershipAvailability = driveDefinition.ownershipAvailability; driveDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true, }; mockToolsList([ { name: "search_files", annotations: { readOnlyHint: true } }, { name: "create_file", annotations: { readOnlyHint: false } }, { name: "delete_file", annotations: { destructiveHint: true } }, ]); try { const connected = await service.connectGalleryApp( company.id, { galleryKey: "google-drive", connectionMethodKey: "paperclip-write", grantKind: "user", name: "Drive managed write", }, actor, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const state = new URL(started.authorizationUrl).searchParams.get( "state", )!; const app = createRouteApp( db, boardSessionActor(company.id, "owner", userId), undefined, { paperclipCloudConnector: connector }, ); const callback = await request(app) .get("/api/tools/oauth/cloud-connector/callback") .query({ state, claim_id: "drive-write-claim" }) .set("accept", "application/json"); expect(callback.status).toBe(200); expect(callback.body.connection.config.quarantineNewEntries).toBe(true); expect(callback.body.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "search_files", status: "active", riskLevel: "read", }), expect.objectContaining({ toolName: "create_file", status: "active", riskLevel: "write", }), expect.objectContaining({ toolName: "delete_file", status: "disabled", riskLevel: "destructive", }), ]), ); const [profileRow] = await db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); const profileEntries = await db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, profileRow!.id)); expect( profileEntries.map((entry) => entry.catalogEntryId).sort(), ).toEqual( callback.body.catalog .filter((entry: { status: string }) => entry.status === "active") .map((entry: { id: string }) => entry.id) .sort(), ); const searchEntry = callback.body.catalog.find( (entry: { toolName: string }) => entry.toolName === "search_files", ); const createEntry = callback.body.catalog.find( (entry: { toolName: string }) => entry.toolName === "create_file", ); await expect( db .select() .from(toolPolicies) .where( and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), ), ), ).resolves.toEqual([]); await expect( db .select() .from(toolConnectionInstalls) .where( and( eq(toolConnectionInstalls.connectionId, connected.connectionId), eq(toolConnectionInstalls.targetType, "company"), ), ), ).resolves.toHaveLength(1); const agent = await createAgent(db, company.id); await db .delete(toolProfileEntries) .where( and( eq(toolProfileEntries.profileId, profileRow!.id), eq(toolProfileEntries.catalogEntryId, searchEntry.id), ), ); await db .delete(toolProfileBindings) .where(eq(toolProfileBindings.profileId, profileRow!.id)); await db.insert(toolProfileBindings).values({ companyId: company.id, profileId: profileRow!.id, targetType: "agent", targetId: agent.id, }); await db .update(toolProfiles) .set({ status: "archived" }) .where(eq(toolProfiles.id, profileRow!.id)); mockToolsList([ { name: "search_files", description: "Search files with a changed contract.", annotations: { readOnlyHint: true }, }, { name: "create_file", annotations: { readOnlyHint: false } }, { name: "copy_file", annotations: { readOnlyHint: false } }, { name: "delete_file", annotations: { destructiveHint: true } }, ]); const reconnect = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const reconnectState = new URL( reconnect.authorizationUrl, ).searchParams.get("state")!; const reconnected = await request(app) .get("/api/tools/oauth/cloud-connector/callback") .query({ state: reconnectState, claim_id: "drive-write-reconnect-claim", }) .set("accept", "application/json"); expect(reconnected.status).toBe(200); expect(reconnected.body.connection.config.quarantineNewEntries).toBe( true, ); expect(reconnected.body.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "search_files", status: "quarantined", }), expect.objectContaining({ toolName: "create_file", status: "active", }), expect.objectContaining({ toolName: "copy_file", status: "quarantined", }), expect.objectContaining({ toolName: "delete_file", status: "disabled", }), ]), ); await expect( db .select() .from(toolProfiles) .where(eq(toolProfiles.id, profileRow!.id)), ).resolves.toEqual([expect.objectContaining({ status: "archived" })]); await expect( db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, profileRow!.id)), ).resolves.toEqual([ expect.objectContaining({ catalogEntryId: createEntry.id, effect: "include", }), ]); await expect( db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, profileRow!.id)), ).resolves.toEqual([ expect.objectContaining({ targetType: "agent", targetId: agent.id }), ]); await expect( db .select() .from(toolPolicies) .where(eq(toolPolicies.companyId, company.id)), ).resolves.toEqual([]); } finally { driveDefinition.ownershipAvailability = previousOwnershipAvailability; } }); it("keeps a managed draft retryable when default finalization fails", async () => { const company = await createCompany(db); const userId = `drive-finalize-failure-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const connector = fakeGoogleWorkspaceConnector( company.id, userId, "drive.write", ); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; const driveDefinition = getConnectableAppDefinition("google-drive")!; const previousOwnershipAvailability = driveDefinition.ownershipAvailability; driveDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true, }; mockToolsList([ { name: "search_files", annotations: { readOnlyHint: true } }, { name: "create_file", annotations: { readOnlyHint: false } }, { name: "delete_file", annotations: { destructiveHint: true } }, ]); try { const connected = await service.connectGalleryApp( company.id, { galleryKey: "google-drive", connectionMethodKey: "paperclip-write", grantKind: "user", name: "Drive managed finalize failure", }, actor, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const state = new URL(started.authorizationUrl).searchParams.get( "state", )!; const originalTransaction = db.transaction.bind(db); let transactionCount = 0; const transactionSpy = vi .spyOn(db, "transaction") .mockImplementation((async (operation, config) => { transactionCount += 1; if (transactionCount === 2) throw new Error("recommended defaults failed"); return originalTransaction(operation, config); }) as typeof db.transaction); await expect( service.completePaperclipCloudConnectorCallback({ state, claimId: "drive-finalize-failure-claim", actor, }), ).rejects.toThrow("recommended defaults failed"); await expect( db .select() .from(toolCatalogEntries) .where(eq(toolCatalogEntries.connectionId, connected.connectionId)), ).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "search_files", status: "quarantined", }), expect.objectContaining({ toolName: "create_file", status: "quarantined", }), expect.objectContaining({ toolName: "delete_file", status: "disabled", }), ]), ); await expect( db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)), ).resolves.toHaveLength(0); await expect( service.getConnection(connected.connectionId), ).resolves.toMatchObject({ status: "draft", enabled: false, config: { quarantineNewEntries: true }, }); await expect( db .select() .from(toolApplications) .where(eq(toolApplications.id, connected.application.id)), ).resolves.toEqual([expect.objectContaining({ status: "draft" })]); transactionSpy.mockRestore(); const retry = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const completed = await service.completePaperclipCloudConnectorCallback({ state: new URL(retry.authorizationUrl).searchParams.get("state")!, claimId: "drive-finalize-retry-claim", actor, }); expect(completed.connection).toMatchObject({ status: "active", enabled: true, config: { quarantineNewEntries: true }, }); expect(completed.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "search_files", status: "active", }), expect.objectContaining({ toolName: "create_file", status: "active", }), expect.objectContaining({ toolName: "delete_file", status: "disabled", }), ]), ); const [profileRow] = await db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); const activeEntryIds = completed.catalog .filter((entry) => entry.status === "active") .map((entry) => entry.id) .sort(); await expect( db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, profileRow!.id)) .then((rows) => rows.map((entry) => entry.catalogEntryId).sort()), ).resolves.toEqual(activeEntryIds); await expect( db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, profileRow!.id)), ).resolves.toEqual([ expect.objectContaining({ targetType: "company", targetId: company.id, }), ]); await expect( db .select() .from(toolPolicies) .where( and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), ), ), ).resolves.toEqual([]); await expect( db .select() .from(toolConnectionInstalls) .where( and( eq(toolConnectionInstalls.connectionId, connected.connectionId), eq(toolConnectionInstalls.targetType, "company"), ), ), ).resolves.toHaveLength(1); await expect( db .select() .from(toolApplications) .where(eq(toolApplications.id, connected.application.id)), ).resolves.toEqual([expect.objectContaining({ status: "active" })]); mockToolsList([ { name: "search_files", description: "Changed after retry.", annotations: { readOnlyHint: true }, }, { name: "create_file", annotations: { readOnlyHint: false } }, { name: "copy_file", annotations: { readOnlyHint: false } }, { name: "delete_file", annotations: { destructiveHint: true } }, ]); const futureRefresh = await service.refreshCatalog( connected.connectionId, actor, ); expect(futureRefresh.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "search_files", status: "quarantined", }), expect.objectContaining({ toolName: "create_file", status: "active", }), expect.objectContaining({ toolName: "copy_file", status: "quarantined", }), expect.objectContaining({ toolName: "delete_file", status: "disabled", }), ]), ); expect(futureRefresh.connection.config.quarantineNewEntries).toBe(true); } finally { driveDefinition.ownershipAvailability = previousOwnershipAvailability; } }); it("rebuilds managed defaults when a removed connection is revived with its archived profile retained", async () => { const company = await createCompany(db); const userId = `drive-revival-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const connector = fakeGoogleWorkspaceConnector( company.id, userId, "drive.write", ); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; const driveDefinition = getConnectableAppDefinition("google-drive")!; const previousOwnershipAvailability = driveDefinition.ownershipAvailability; driveDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true, }; mockToolsList([ { name: "search_files", annotations: { readOnlyHint: true } }, { name: "create_file", annotations: { readOnlyHint: false } }, { name: "delete_file", annotations: { destructiveHint: true } }, ]); try { const first = await service.connectGalleryApp( company.id, { galleryKey: "google-drive", connectionMethodKey: "paperclip-write", grantKind: "user", name: "Drive managed revival", }, actor, ); const firstStart = await service.startOAuth( company.id, first.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); await service.completePaperclipCloudConnectorCallback({ state: new URL(firstStart.authorizationUrl).searchParams.get("state")!, claimId: "drive-revival-first-claim", actor, }); const [retainedProfile] = await db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${first.connectionId}`)); await db.insert(toolMcpGateways).values({ companyId: company.id, name: `Retained managed gateway ${randomUUID()}`, slug: `retained-managed-${randomUUID()}`, profileId: retainedProfile!.id, status: "active", }); await service.archiveConnection(first.connectionId, company.id, actor); await expect( db .select() .from(toolProfiles) .where(eq(toolProfiles.id, retainedProfile!.id)), ).resolves.toEqual([expect.objectContaining({ status: "archived" })]); const revived = await service.connectGalleryApp( company.id, { galleryKey: "google-drive", connectionMethodKey: "paperclip-write", grantKind: "user", name: "Drive managed revival", }, actor, ); expect(revived.connectionId).toBe(first.connectionId); expect(revived.connection).toMatchObject({ status: "draft", enabled: false, }); await expect( db .select() .from(toolProfiles) .where(eq(toolProfiles.id, retainedProfile!.id)), ).resolves.toEqual([expect.objectContaining({ status: "archived" })]); const revivedStart = await service.startOAuth( company.id, revived.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const completed = await service.completePaperclipCloudConnectorCallback({ state: new URL(revivedStart.authorizationUrl).searchParams.get( "state", )!, claimId: "drive-revival-second-claim", actor, }); expect(completed.connection).toMatchObject({ status: "active", enabled: true, config: { quarantineNewEntries: true }, }); expect(completed.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "search_files", status: "active", }), expect.objectContaining({ toolName: "create_file", status: "active", }), expect.objectContaining({ toolName: "delete_file", status: "disabled", }), ]), ); await expect( db .select() .from(toolProfiles) .where(eq(toolProfiles.id, retainedProfile!.id)), ).resolves.toEqual([expect.objectContaining({ status: "active" })]); const revivedEntries = await db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, retainedProfile!.id)); expect( revivedEntries.map((entry) => entry.catalogEntryId).sort(), ).toEqual( completed.catalog .filter((entry) => entry.status === "active") .map((entry) => entry.id) .sort(), ); await expect( db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, retainedProfile!.id)), ).resolves.toEqual([ expect.objectContaining({ targetType: "company", targetId: company.id, }), ]); await expect( db .select() .from(toolPolicies) .where( and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), ), ), ).resolves.toEqual([]); } finally { driveDefinition.ownershipAvailability = previousOwnershipAvailability; } }); it("keeps brokered OAuth state retryable until credentials are durably stored", async () => { const company = await createCompany(db); const userId = `gmail-retry-${randomUUID()}`; await grantBoardUser(db, company.id, userId, []); const connector = fakeGmailConnector(company.id, userId); vi.mocked(connector.claim).mockRejectedValueOnce( new Error("temporary claim failure"), ); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector, }); const actor = { actorType: "user" as const, actorId: userId }; const gmailDefinition = getConnectableAppDefinition("gmail")!; const previousOwnershipAvailability = gmailDefinition.ownershipAvailability; gmailDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true, }; mockToolsList([]); try { const connected = await service.connectGalleryApp( company.id, { galleryKey: "gmail", connectionMethodKey: "paperclip-draft", grantKind: "user", name: "Gmail retryable callback", }, actor, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const state = new URL(started.authorizationUrl).searchParams.get( "state", )!; await expect( service.completePaperclipCloudConnectorCallback({ state, claimId: "gmail-retry-claim", actor, }), ).rejects.toThrow("temporary claim failure"); await expect(service.peekOAuthState(state)).resolves.toMatchObject({ companyId: company.id, connectionId: connected.connectionId, subjectUserId: userId, }); await expect( service.completePaperclipCloudConnectorCallback({ state, claimId: "gmail-retry-claim", actor, }), ).resolves.toMatchObject({ connection: { status: "active", enabled: true }, }); await expect(service.peekOAuthState(state)).resolves.toBeNull(); expect(connector.claim).toHaveBeenNthCalledWith( 1, expect.objectContaining({ redemptionId: state }), ); expect(connector.claim).toHaveBeenNthCalledWith( 2, expect.objectContaining({ redemptionId: state }), ); } finally { gmailDefinition.ownershipAvailability = previousOwnershipAvailability; } }); it("serializes brokered Gmail OAuth completion behind membership revocation", async () => { const company = await createCompany(db); const userId = `gmail-member-${randomUUID()}`; await grantBoardUser(db, company.id, userId, []); const callbackDb = createDb(tempDb!.connectionString, { maxConnections: 1, }); const removalDb = createDb(tempDb!.connectionString, { maxConnections: 1 }); const service = createTestToolAccessService(callbackDb, { paperclipCloudConnector: fakeGmailConnector(company.id, userId), }); const actor = { actorType: "user" as const, actorId: userId }; const gmailDefinition = getConnectableAppDefinition("gmail")!; const previousOwnershipAvailability = gmailDefinition.ownershipAvailability; gmailDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true, }; let releaseRemoval!: () => void; const removalMayCommit = new Promise((resolve) => { releaseRemoval = resolve; }); let membershipLocked!: () => void; const membershipIsLocked = new Promise((resolve) => { membershipLocked = resolve; }); let removal: Promise | null = null; mockToolsList([]); try { const connected = await service.connectGalleryApp( company.id, { galleryKey: "gmail", connectionMethodKey: "paperclip-draft", grantKind: "user", name: "Gmail concurrent revocation callback", }, actor, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, }, ); const state = new URL(started.authorizationUrl).searchParams.get( "state", )!; const beforeSecrets = await db .select() .from(companySecrets) .where(eq(companySecrets.companyId, company.id)); const beforeGrants = await db .select() .from(connectionGrants) .where(eq(connectionGrants.connectionId, connected.connectionId)); removal = removalDb.transaction(async (tx) => { await tx .select({ id: companyMemberships.id }) .from(companyMemberships) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), ), ) .for("update"); membershipLocked(); await removalMayCommit; await tx .update(companyMemberships) .set({ membershipRole: "viewer", updatedAt: new Date(), }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), ), ); }); await membershipIsLocked; const completion = service .completePaperclipCloudConnectorCallback({ state, claimId: "gmail-claim", actor, }) .then( (value) => ({ value, error: null }), (error: unknown) => ({ value: null, error }), ); expect(await waitForBlockedMembershipUpdate()).toBe(true); releaseRemoval(); await removal; const outcome = await completion; expect(outcome.value).toBeNull(); expect(outcome.error).toMatchObject({ status: 403, message: expect.stringContaining( "membership no longer permits connection changes", ), }); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect(connection).toMatchObject({ status: "draft", enabled: false }); await expect( db .select() .from(companySecrets) .where(eq(companySecrets.companyId, company.id)), ).resolves.toHaveLength(beforeSecrets.length); await expect( db .select() .from(connectionGrants) .where(eq(connectionGrants.connectionId, connected.connectionId)), ).resolves.toEqual(beforeGrants); } finally { gmailDefinition.ownershipAvailability = previousOwnershipAvailability; releaseRemoval(); await removal?.catch(() => undefined); await callbackDb.$client.end({ timeout: 0 }).catch(() => undefined); await removalDb.$client.end({ timeout: 0 }).catch(() => undefined); } }, 15_000); it("synchronizes shared OAuth credentials to the organization grant used by gateway calls", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); const userId = `oauth-owner-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [ "tools:use", "tools:manage_connections", ]); const agent = await createAgent(db, company.id); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp( company.id, { galleryKey: "slack", name: "Shared OAuth grant", }, { actorType: "user", actorId: userId }, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, }, ); let gatewayAuthorization: string | null = null; vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { return { ok: true, status: 200, json: async () => ({ access_token: "shared-access-token", refresh_token: "shared-refresh-token", expires_in: 3600, }), } as Response; } if (href === "https://mcp.slack.com/mcp") { const payload = JSON.parse(String(init?.body ?? "{}")) as { id?: string; method?: string; }; if (payload.method === "tools/call") { gatewayAuthorization = new Headers(init?.headers).get( "authorization", ); return mcpHttpResponse({ jsonrpc: "2.0", id: payload.id, result: { content: [{ type: "text", text: "channel details" }] }, }); } if (payload.method === "tools/list") { return mcpHttpResponse({ jsonrpc: "2.0", id: payload.id, result: { tools: [ { name: "get_channel", description: "Read a Slack channel.", inputSchema: { type: "object", properties: { channel: { type: "string" } }, }, annotations: { readOnlyHint: true }, }, ], }, }); } return mcpHttpResponse({ jsonrpc: "2.0", id: payload.id, result: { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "Slack test", version: "1.0.0" }, }, }); } throw new Error(`unexpected fetch ${href}`); }); await service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "shared-authorization-code", redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, }); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); const [organizationGrant] = await db .select() .from(connectionGrants) .where( and( eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.kind, "organization"), eq(connectionGrants.isDefault, true), ), ); expect(organizationGrant).toMatchObject({ status: "active" }); expect( organizationGrant.credentialSecretRefs.map((ref) => ref.secretId).sort(), ).toEqual( connection.credentialSecretRefs.map((ref) => ref.secretId).sort(), ); expect( organizationGrant.credentialSecretRefs .map((ref) => ref.configPath) .sort(), ).toEqual(["oauth.access_token", "oauth.refresh_token"]); await db.insert(toolPolicies).values({ companyId: company.id, name: `Allow shared OAuth read ${randomUUID()}`, policyType: "allow", priority: 100, selectors: { connectionId: connected.connectionId }, }); const app = createRouteApp( db, boardSessionActor(company.id, "operator", userId), createToolGatewayService(db, { toolActionSigningSecret: "test-secret" }), ); await request(app) .post(`/api/tool-connections/${connected.connectionId}/test-calls`) .send({ agentId: agent.id, toolName: "get_channel", parameters: { channel: "general" }, }) .expect(200); expect(gatewayAuthorization).toBe("Bearer shared-access-token"); }); it("creates and resolves an agent-initiated user authorization grant card", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); await grantBoardUser(db, company.id, "workspace-owner", [ "tools:manage_connections", ]); const agent = await createAgent(db, company.id); const { issue, run } = await createIssueAndRun(db, company.id, agent.id); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack user auth", }); const workspaceStarted = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: "workspace-owner" }, }, ); const workspaceState = new URL( workspaceStarted.authorizationUrl, ).searchParams.get("state")!; vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { const body = init?.body as URLSearchParams; const userAuthorization = body.get("code") === "user-authorization-code"; return { ok: true, status: 200, json: async () => ({ access_token: userAuthorization ? "user-access-token" : "workspace-access-token", refresh_token: userAuthorization ? "user-refresh-token" : "workspace-refresh-token", expires_in: 3600, }), } as Response; } if (href === "https://mcp.slack.com/mcp") { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] }, }); } throw new Error(`unexpected fetch ${href}`); }); await service.completeOAuthCallback({ state: workspaceState, code: "workspace-authorization-code", redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: "workspace-owner" }, }); const [workspaceConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); const workspaceSecretIds = workspaceConnection.credentialSecretRefs .map((ref) => ref.secretId) .sort(); const started = await service.startAuthorizationForAgent({ companyId: company.id, connectionId: connected.connectionId, agentId: agent.id, runId: run.id, subjectUserId: "user-for-run", scopes: ["channels:read"], redirectUri: "https://paperclip.example/api/tools/oauth/callback", }); const authorizationUrl = new URL(started.authorizationUrl); expect(authorizationUrl.searchParams.get("scope")).toBe("channels:read"); const [state] = await db.select().from(toolOauthStates); expect(state).toMatchObject({ subjectUserId: "user-for-run", issueId: issue.id, requestedScopes: ["channels:read"], }); const [interaction] = await db.select().from(issueThreadInteractions); expect(interaction).toMatchObject({ issueId: issue.id, kind: "request_confirmation", status: "pending", title: "Connect your Slack to continue", }); expect(interaction.payload).toMatchObject({ target: { href: started.authorizationUrl }, }); await service.completeOAuthCallback({ state: state.state, code: "user-authorization-code", redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: "user-for-run" }, }); const [grant] = await db .select() .from(connectionGrants) .where( and( eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.subjectUserId, "user-for-run"), ), ); expect(grant).toMatchObject({ kind: "user", status: "active" }); expect( grant.credentialSecretRefs.map((ref) => ref.configPath).sort(), ).toEqual(["oauth.access_token", "oauth.refresh_token"]); expect( grant.credentialSecretRefs.map((ref) => ref.secretId).sort(), ).not.toEqual(workspaceSecretIds); const personalSecrets = await db .select() .from(companySecrets) .where( inArray( companySecrets.id, grant.credentialSecretRefs.map((ref) => ref.secretId), ), ); expect(personalSecrets).toHaveLength(2); expect(personalSecrets).toEqual( expect.arrayContaining([ expect.objectContaining({ scope: "user", ownerUserId: "user-for-run" }), ]), ); expect( personalSecrets.every((secret) => secret.userSecretDefinitionId !== null), ).toBe(true); const [unchangedConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect( unchangedConnection.credentialSecretRefs .map((ref) => ref.secretId) .sort(), ).toEqual(workspaceSecretIds); const [resolved] = await db .select() .from(issueThreadInteractions) .where(eq(issueThreadInteractions.id, interaction.id)); expect(resolved).toMatchObject({ status: "accepted", result: { version: 1, outcome: "accepted" }, }); const versionCountBeforeAccessRevocation = ( await db .select() .from(companySecretVersions) .where( inArray( companySecretVersions.secretId, grant.credentialSecretRefs.map((ref) => ref.secretId), ), ) ).length; await db .update(companyMemberships) .set({ membershipRole: "viewer" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "user-for-run"), ), ); await expect( service.startAuthorizationForAgent({ companyId: company.id, connectionId: connected.connectionId, agentId: agent.id, runId: run.id, subjectUserId: "user-for-run", scopes: ["channels:read"], redirectUri: "https://paperclip.example/api/tools/oauth/callback", }), ).rejects.toMatchObject({ status: 403 }); await db .update(companyMemberships) .set({ membershipRole: "member" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "user-for-run"), ), ); const retry = await service.startAuthorizationForAgent({ companyId: company.id, connectionId: connected.connectionId, agentId: agent.id, runId: run.id, subjectUserId: "user-for-run", scopes: ["channels:read"], redirectUri: "https://paperclip.example/api/tools/oauth/callback", }); await db .update(companyMemberships) .set({ membershipRole: "viewer" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "user-for-run"), ), ); await expect( service.completeOAuthCallback({ state: new URL(retry.authorizationUrl).searchParams.get("state")!, code: "user-authorization-code", redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: "user-for-run" }, }), ).rejects.toMatchObject({ status: 403 }); expect( ( await db .select() .from(companySecretVersions) .where( inArray( companySecretVersions.secretId, grant.credentialSecretRefs.map((ref) => ref.secretId), ), ) ).length, ).toBe(versionCountBeforeAccessRevocation); await db .update(companyMemberships) .set({ membershipRole: "member" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "user-for-run"), ), ); const suspendedRetry = await service.startAuthorizationForAgent({ companyId: company.id, connectionId: connected.connectionId, agentId: agent.id, runId: run.id, subjectUserId: "user-for-run", scopes: ["channels:read"], redirectUri: "https://paperclip.example/api/tools/oauth/callback", }); await db .update(companyMemberships) .set({ status: "suspended" }) .where( and( eq(companyMemberships.companyId, company.id), eq(companyMemberships.principalId, "user-for-run"), ), ); await expect( service.completeOAuthCallback({ state: new URL(suspendedRetry.authorizationUrl).searchParams.get( "state", )!, code: "user-authorization-code", redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: "user-for-run" }, }), ).rejects.toMatchObject({ status: 403 }); expect( ( await db .select() .from(companySecretVersions) .where( inArray( companySecretVersions.secretId, grant.credentialSecretRefs.map((ref) => ref.secretId), ), ) ).length, ).toBe(versionCountBeforeAccessRevocation); }); it.each(["page", "task"] as const)( "activates and discovers actions for a fresh personal OAuth callback from %s without widening task access", async (host) => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); const userId = `oauth-owner-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const agent = await createAgent(db, company.id); const otherAgent = await createAgent(db, company.id); const { issue, run } = await createIssueAndRun(db, company.id, agent.id); const interaction = host === "task" ? ( await db .insert(issueThreadInteractions) .values({ companyId: company.id, issueId: issue.id, sourceRunId: run.id, kind: "connection_intent", status: "pending", createdByAgentId: agent.id, addresseeUserId: userId, payload: { version: 1, serviceSlug: "slack", serviceName: "Slack", requestingAgentId: agent.id, requestingAgentName: agent.name, phase: "authorizing", }, }) .returning() )[0] : undefined; const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp( company.id, { galleryKey: "slack", name: "Personal OAuth callback", grantKind: "user", }, { actorType: "user", actorId: userId }, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, subjectUserId: userId, interactionId: interaction?.id, }, ); vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { const code = (init?.body as URLSearchParams).get("code"); expect(["personal-code", "personal-reconnect-code"]).toContain(code); const reconnecting = code === "personal-reconnect-code"; return mcpHttpResponse({ ok: true, access_token: reconnecting ? "personal-access-token-2" : "personal-access-token", refresh_token: reconnecting ? "personal-refresh-token-2" : "personal-refresh-token", expires_in: 3600, token_type: "Bearer", }); } if (href === "https://mcp.slack.com/mcp") { expect(init?.headers).toEqual( expect.objectContaining({ Authorization: expect.stringMatching( /^Bearer personal-access-token(?:-2)?$/, ), }), ); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "search_messages", annotations: { readOnlyHint: true }, }, { name: "send_message", annotations: { readOnlyHint: false } }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); const completed = await service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "personal-code", redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, }); expect(completed.connection).toMatchObject({ status: "active", enabled: true, credentialPolicy: "per_user", healthStatus: "ok", }); expect(completed.catalog.map((entry) => entry.toolName).sort()).toEqual([ "search_messages", "send_message", ]); expect(completed.connection.credentialSecretRefs).toEqual([]); const [callbackProfile] = await db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); await expect( db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, callbackProfile!.id)), ).resolves.toHaveLength(2); const searchMessagesEntry = completed.catalog.find( (entry) => entry.toolName === "search_messages", )!; const sendMessageEntry = completed.catalog.find( (entry) => entry.toolName === "send_message", )!; await expect( db .select() .from(toolPolicies) .where( and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), ), ), ).resolves.toEqual([]); const callbackPolicy = toolAccessPolicyService(db); const decide = ( entry: (typeof completed.catalog)[number], agentId = agent.id, ) => callbackPolicy.decide({ companyId: company.id, actor: { actorType: "agent", actorId: agentId, agentId }, request: { connectionId: connected.connectionId, catalogEntryId: entry.id, toolName: entry.toolName, arguments: {}, }, }); for (const entry of [searchMessagesEntry, sendMessageEntry]) { await expect(decide(entry)).resolves.toMatchObject( host === "task" ? { decision: "deny" } : { decision: "allow", reasonCode: "allow_profile" }, ); } if (host === "task") { await expect( db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, callbackProfile!.id)), ).resolves.toEqual([]); await expect( db .select() .from(toolConnectionInstalls) .where( eq(toolConnectionInstalls.connectionId, connected.connectionId), ), ).resolves.toEqual([]); await expect( db .select() .from(issueThreadInteractions) .where(eq(issueThreadInteractions.id, interaction!.id)), ).resolves.toEqual([ expect.objectContaining({ status: "pending", result: null }), ]); } const [personalGrant] = await db .select() .from(connectionGrants) .where( and( eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.subjectUserId, userId), ), ); expect(personalGrant).toMatchObject({ status: "active", kind: "user" }); expect( personalGrant.credentialSecretRefs.map((ref) => ref.configPath).sort(), ).toEqual(["oauth.access_token", "oauth.refresh_token"]); const finished = await service.finalizeOAuthAccess( company.id, connected.connectionId, { grantKind: "user", }, { actorType: "user", actorId: userId }, host === "task" ? agent.id : undefined, ); expect(finished.profileEntries).toHaveLength(2); expect(finished.profileBindings).toEqual([ expect.objectContaining( host === "task" ? { targetType: "agent", targetId: agent.id } : { targetType: "company", targetId: company.id }, ), ]); await expect( db .select() .from(toolConnectionInstalls) .where( and( eq(toolConnectionInstalls.connectionId, connected.connectionId), eq(toolConnectionInstalls.targetType, "company"), ), ), ).resolves.toHaveLength(host === "task" ? 0 : 1); if (host === "task") { await expect(decide(searchMessagesEntry)).resolves.toMatchObject({ decision: "allow", }); await expect( decide(searchMessagesEntry, otherAgent.id), ).resolves.toMatchObject({ decision: "deny" }); // Granting a second requester and retrying the first must preserve both installs. for (const requestingAgentId of [otherAgent.id, agent.id]) { await service.finalizeOAuthAccess( company.id, connected.connectionId, { grantKind: "user", }, { actorType: "user", actorId: userId }, requestingAgentId, ); } const bindings = await db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, callbackProfile!.id)); expect( bindings.map(({ targetType, targetId }) => ({ targetType, targetId, })), ).toEqual( expect.arrayContaining( [agent.id, otherAgent.id].map((targetId) => ({ targetType: "agent", targetId, })), ), ); expect(bindings).toHaveLength(2); const installs = await db .select() .from(toolConnectionInstalls) .where( eq(toolConnectionInstalls.connectionId, connected.connectionId), ); expect( installs.map(({ targetType, targetId }) => ({ targetType, targetId, })), ).toEqual( expect.arrayContaining( [agent.id, otherAgent.id].map((targetId) => ({ targetType: "agent", targetId, })), ), ); expect(installs).toHaveLength(2); } await expect( service.startOAuth(company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: `different-user-${randomUUID()}`, }, }), ).rejects.toMatchObject({ status: 403 }); const personalSecretIds = personalGrant.credentialSecretRefs .map((ref) => ref.secretId) .sort(); await db .update(connectionGrants) .set({ status: "revoked", credentialSecretRefs: [], revokedAt: new Date(), revokedByUserId: userId, updatedAt: new Date(), }) .where(eq(connectionGrants.id, personalGrant.id)); await db .update(toolConnections) .set({ status: "draft", enabled: false, updatedAt: new Date(), }) .where(eq(toolConnections.id, connected.connectionId)); const reconnect = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, interactionId: interaction?.id, }, ); await expect( service.peekOAuthState( new URL(reconnect.authorizationUrl).searchParams.get("state")!, ), ).resolves.toMatchObject({ subjectUserId: userId }); await expect( service.completeOAuthCallback({ state: new URL(reconnect.authorizationUrl).searchParams.get("state")!, code: "personal-reconnect-code", redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, }), ).resolves.toMatchObject({ connection: { status: "active", enabled: true }, }); const [revivedGrant] = await db .select() .from(connectionGrants) .where(eq(connectionGrants.id, personalGrant.id)); expect(revivedGrant).toMatchObject({ status: "active" }); expect( revivedGrant.credentialSecretRefs.map((ref) => ref.secretId).sort(), ).toEqual(personalSecretIds); const revivedSecrets = await db .select() .from(companySecrets) .where(inArray(companySecrets.id, personalSecretIds)); expect(revivedSecrets).toHaveLength(2); expect(revivedSecrets.every((secret) => secret.latestVersion === 2)).toBe( true, ); if (host === "task") { const installs = await db .select() .from(toolConnectionInstalls) .where( eq(toolConnectionInstalls.connectionId, connected.connectionId), ); expect(installs).toHaveLength(2); expect( installs.every((install) => install.targetType === "agent"), ).toBe(true); for (const allowedAgentId of [agent.id, otherAgent.id]) { await expect( decide(searchMessagesEntry, allowedAgentId), ).resolves.toMatchObject({ decision: "allow" }); } } }, ); it("promotes a personal OAuth identity only after Everyone in the company is chosen", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); const userId = `oauth-sharer-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp( company.id, { galleryKey: "slack", name: "Shared after OAuth", grantKind: "user", }, { actorType: "user", actorId: userId }, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, subjectUserId: userId, }, ); vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { return mcpHttpResponse({ ok: true, access_token: "share-access-token", refresh_token: "share-refresh-token", expires_in: 3600, token_type: "Bearer", }); } if (href === "https://mcp.slack.com/mcp") { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "search_messages", annotations: { readOnlyHint: true } }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); await service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "share-code", redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, }); const [beforeGrant] = await db .select() .from(connectionGrants) .where( and( eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.subjectUserId, userId), ), ); const personalSecretIds = beforeGrant.credentialSecretRefs.map( (ref) => ref.secretId, ); await service.finalizeOAuthAccess( company.id, connected.connectionId, { grantKind: "organization", }, { actorType: "user", actorId: userId }, ); const promotedConnection = await service.getConnection( connected.connectionId, company.id, ); expect(promotedConnection).toMatchObject({ credentialPolicy: "shared", status: "active", enabled: true, }); expect( promotedConnection.credentialSecretRefs .map((ref) => ref.configPath) .sort(), ).toEqual(["oauth.access_token", "oauth.refresh_token"]); const { grants } = await service.listConnectionGrants( connected.connectionId, company.id, ); expect(grants).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "organization", isDefault: true, status: "active", }), expect.objectContaining({ kind: "user", subjectUserId: userId, status: "revoked", credentialSecretRefs: [], }), ]), ); const organizationGrant = grants.find( (grant) => grant.kind === "organization", )!; expect( organizationGrant.credentialSecretRefs.map((ref) => ref.secretId).sort(), ).toEqual( promotedConnection.credentialSecretRefs.map((ref) => ref.secretId).sort(), ); await expect( db .select() .from(connectionGrantMembers) .where(eq(connectionGrantMembers.grantId, organizationGrant.id)), ).resolves.toHaveLength(0); await expect( db .select() .from(companySecrets) .where(inArray(companySecrets.id, personalSecretIds)), ).resolves.toHaveLength(0); const promotedSecrets = await db .select() .from(companySecrets) .where( inArray( companySecrets.id, promotedConnection.credentialSecretRefs.map((ref) => ref.secretId), ), ); expect( promotedSecrets.every( (secret) => secret.scope === "company" && secret.ownerUserId === null, ), ).toBe(true); }); it("refreshes an expired personal OAuth grant during health checks without moving its tokens onto the connection", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); const userId = `oauth-refresh-owner-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp( company.id, { galleryKey: "slack", name: "Personal OAuth refresh", grantKind: "user", }, { actorType: "user", actorId: userId }, ); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, subjectUserId: userId, }, ); const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url, init) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { const body = init?.body as URLSearchParams; if (body.get("grant_type") === "refresh_token") { expect(body.get("refresh_token")).toBe("personal-refresh-token"); return mcpHttpResponse({ ok: true, access_token: "refreshed-personal-access-token", refresh_token: "rotated-personal-refresh-token", expires_in: 3600, token_type: "Bearer", }); } return mcpHttpResponse({ ok: true, access_token: "personal-access-token", refresh_token: "personal-refresh-token", expires_in: 3600, token_type: "Bearer", }); } if (href === "https://mcp.slack.com/mcp") { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "search_messages", annotations: { readOnlyHint: true }, }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); await service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "personal-code", redirectUri: "https://paperclip.example/api/tools/oauth/callback", actor: { actorType: "user", actorId: userId }, }); const [grant] = await db .select() .from(connectionGrants) .where( and( eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.subjectUserId, userId), ), ); await db .update(connectionGrants) .set({ providerTenant: { ...(grant.providerTenant ?? {}), oauth: { ...(grant.providerTenant?.oauth ?? {}), accessTokenExpiresAt: "2000-01-01T00:00:00.000Z", }, }, }) .where(eq(connectionGrants.id, grant.id)); fetchMock.mockClear(); const health = await service.checkHealth(connected.connectionId, { actorType: "system", actorId: "health-check", }); const [refreshed] = await db .select() .from(connectionGrants) .where(eq(connectionGrants.id, grant.id)); expect(health.connection.healthStatus).toBe("ok"); expect(fetchMock).toHaveBeenCalledTimes(2); expect(refreshed).toMatchObject({ status: "active", kind: "user", subjectUserId: userId, }); expect( Date.parse(refreshed.providerTenant?.oauth?.accessTokenExpiresAt ?? ""), ).toBeGreaterThan(Date.now()); expect(refreshed.providerTenant?.oauth?.refreshLease).toBeUndefined(); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect(connection.credentialSecretRefs).toEqual([]); const versions = await db .select() .from(companySecretVersions) .where( inArray( companySecretVersions.secretId, refreshed.credentialSecretRefs.map((ref) => ref.secretId), ), ); expect(versions).toHaveLength(4); expect( versions.filter((version) => version.status === "current"), ).toHaveLength(2); }); it("returns a pre-scoped personal Notion callback directly to Permissions", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); const userId = `notion-owner-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const app = createRouteApp( db, boardSessionActor(company.id, "owner", userId), ); vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if ( href === "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp" ) { return mcpHttpResponse({ authorization_servers: ["https://mcp.notion.com"], scopes_supported: ["default"], }); } if ( href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { return mcpHttpResponse({ issuer: "https://mcp.notion.com", authorization_endpoint: "https://mcp.notion.com/authorize", token_endpoint: "https://mcp.notion.com/token", registration_endpoint: "https://mcp.notion.com/register", code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], }); } if (href === "https://mcp.notion.com/register") { return mcpHttpResponse({ client_id: "notion-choice-client", client_secret: "notion-choice-secret", redirect_uris: ["https://paperclip.example/api/tools/oauth/callback"], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", }); } if (href === "https://mcp.notion.com/token") { expect((init?.body as URLSearchParams).get("code")).toBe( "notion-choice-code", ); return mcpHttpResponse({ access_token: "notion-choice-access", refresh_token: "notion-choice-refresh", expires_in: 3600, token_type: "Bearer", }); } if (href === "https://mcp.notion.com/mcp") { expect(init?.headers).toEqual( expect.objectContaining({ Authorization: "Bearer notion-choice-access", }), ); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "notion-search", annotations: { readOnlyHint: true } }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ galleryKey: "notion", name: "Notion choice", grantKind: "user" }) .expect(201); const state = new URL(connectRes.body.auth.startUrl).searchParams.get( "state", ); expect(state).toBeTruthy(); const callbackRes = await request(app) .get("/api/tools/oauth/callback") .set("Accept", "text/html") .query({ state, code: "notion-choice-code" }); expect(callbackRes.status).toBe(303); expect(callbackRes.headers.location).toBe( `/${company.issuePrefix}/apps/${connectRes.body.connectionId}/permissions?success=1`, ); const [activeConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connectRes.body.connectionId)); expect(activeConnection).toMatchObject({ status: "active", enabled: true, healthStatus: "ok", credentialPolicy: "per_user", }); await expect( db .select() .from(toolCatalogEntries) .where( eq(toolCatalogEntries.connectionId, connectRes.body.connectionId), ), ).resolves.toEqual([ expect.objectContaining({ toolName: "notion-search", status: "active" }), ]); }); it("returns a declined curated OAuth draft to its exact resumable setup route", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example"); const company = await createCompany(db); const userId = `notion-resume-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const app = createRouteApp( db, boardSessionActor(company.id, "owner", userId), ); vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { const href = String(url); if ( href === "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp" ) { return mcpHttpResponse({ authorization_servers: ["https://mcp.notion.com"], }); } if ( href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { return mcpHttpResponse({ issuer: "https://mcp.notion.com", authorization_endpoint: "https://mcp.notion.com/authorize", token_endpoint: "https://mcp.notion.com/token", registration_endpoint: "https://mcp.notion.com/register", code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], }); } if (href === "https://mcp.notion.com/register") { return mcpHttpResponse({ client_id: "notion-resume-client", redirect_uris: ["https://paperclip.example/api/tools/oauth/callback"], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", }); } throw new Error(`unexpected fetch ${href}`); }); const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ galleryKey: "notion", name: "Notion interrupted", grantKind: "user", }) .expect(201); const state = new URL(connectRes.body.auth.startUrl).searchParams.get( "state", ); expect(state).toBeTruthy(); const callbackRes = await request(app) .get("/api/tools/oauth/callback") .set("Accept", "text/html") .query({ state, error: "access_denied" }); expect(callbackRes.status).toBe(303); const location = new URL( callbackRes.headers.location, "https://paperclip.example", ); expect(location.pathname).toBe(`/${company.issuePrefix}/apps/connect`); expect(location.searchParams.get("source")).toBe("notion"); expect(location.searchParams.get("resume")).toBe( connectRes.body.connectionId, ); expect(location.searchParams.get("oauth")).toBe("denied"); expect(location.searchParams.get("code")).toBe( "oauth_authorization_denied", ); }); it.each([ "https://paperclip-public.example", "http://127.0.0.1:3200", "http://localhost:3200", ])( "starts and completes OAuth with the same redirect URI at %s", async (origin) => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); vi.stubEnv( "PAPERCLIP_PUBLIC_URL", origin.startsWith("https:") ? origin : "", ); const company = await createCompany(db); await grantBoardUser(db, company.id, "board-user", [ "tools:manage_connections", ]); const app = createRouteApp(db); const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .set("Host", new URL(origin).host) .set("Origin", origin) .send({ galleryKey: "slack", name: "Slack workspace" }); expect(connectRes.status).toBe(201); expect(connectRes.body.connection).toMatchObject({ status: "draft", enabled: false, credentialSecretRefs: [], config: expect.objectContaining({ sourceTemplateKey: "slack" }), }); const startUrl = new URL(connectRes.body.auth.startUrl); expect(`${startUrl.origin}${startUrl.pathname}`).toBe( "https://slack.com/oauth/v2/authorize", ); expect(startUrl.searchParams.get("client_id")).toBe("slack-client-id"); expect(startUrl.searchParams.get("code_challenge_method")).toBe("S256"); expect(startUrl.searchParams.get("code_challenge")).toMatch( /^[A-Za-z0-9_-]{43}$/, ); expect(startUrl.searchParams.get("redirect_uri")).toBe( `${origin}/api/tools/oauth/callback`, ); const state = startUrl.searchParams.get("state"); expect(state).toBeTruthy(); await expect(db.select().from(toolOauthStates)).resolves.toEqual([ expect.objectContaining({ state, connectionId: connectRes.body.connectionId, companyId: company.id, createdByActorType: "user", createdByActorId: "board-user", createdBySessionId: null, }), ]); const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url, init) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { const body = init?.body as URLSearchParams; expect(body.get("grant_type")).toBe("authorization_code"); expect(body.get("code")).toBe("oauth-code"); expect(body.get("client_secret")).toBe("slack-client-secret"); expect(body.get("code_verifier")).toBeTruthy(); expect(body.get("redirect_uri")).toBe( `${origin}/api/tools/oauth/callback`, ); return { ok: true, json: async () => ({ ok: true, access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600, token_type: "Bearer", scope: "channels:read chat:write search:read", }), } as Response; } if (href === "https://mcp.slack.com/mcp") { expect(init?.headers).toEqual( expect.objectContaining({ Authorization: "Bearer access-token" }), ); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "search_messages", description: "Search messages.", annotations: { readOnlyHint: true }, }, { name: "send_message", description: "Send a message.", annotations: { readOnlyHint: false }, }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); const callbackRes = await request(app) .get("/api/tools/oauth/callback") .set("Host", new URL(origin).host) .query({ state, code: "oauth-code" }); expect(callbackRes.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(3); expect(callbackRes.body.connection).toMatchObject({ id: connectRes.body.connectionId, status: "active", enabled: true, credentialSecretRefs: [ expect.objectContaining({ configPath: "oauth.access_token", label: "OAuth access token", }), expect.objectContaining({ configPath: "oauth.refresh_token", label: "OAuth refresh token", }), ], }); expect(callbackRes.body.actions.readOnly).toEqual([ expect.objectContaining({ toolName: "search_messages", riskLevel: "read", }), ]); expect(callbackRes.body.actions.canMakeChanges).toEqual([ expect.objectContaining({ toolName: "send_message", riskLevel: "write", }), ]); const redirectConnectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .set("Host", new URL(origin).host) .set("Origin", origin) .send({ galleryKey: "slack", name: "Slack redirect" }) .expect(201); const redirectState = new URL( redirectConnectRes.body.auth.startUrl, ).searchParams.get("state"); expect(redirectState).toBeTruthy(); const redirectCallbackRes = await request(app) .get("/api/tools/oauth/callback") .set("Host", new URL(origin).host) .set("Accept", "text/html") .query({ state: redirectState, code: "oauth-code" }); expect(redirectCallbackRes.status).toBe(303); expect(redirectCallbackRes.headers.location).toBe( `/${company.issuePrefix}/apps/${redirectConnectRes.body.connectionId}/permissions?success=1`, ); expect(fetchMock).toHaveBeenCalledTimes(6); await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0); await expect( db.select().from(companySecretBindings), ).resolves.toHaveLength(6); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connectRes.body.connectionId)); expect(JSON.stringify(connection.config)).not.toContain("access-token"); expect(JSON.stringify(connection.config)).not.toContain("refresh-token"); }, ); it("normalizes a direct numeric loopback origin for OAuth when no public URL is configured", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); const app = createRouteApp(db); const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .set("Host", "127.0.0.1:3200") .send({ galleryKey: "slack", name: "Loopback Slack workspace" }); expect(connectRes.status).toBe(201); const startUrl = new URL(connectRes.body.auth.startUrl); expect(startUrl.searchParams.get("redirect_uri")).toBe( "http://localhost:3200/api/tools/oauth/callback", ); }); it("does not derive an OAuth callback origin from a non-loopback request host", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); const app = createRouteApp(db); const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .set("Host", "paperclip.example.test") .set("X-Forwarded-Host", "127.0.0.1:3200") .send({ galleryKey: "slack", name: "Unconfigured public Slack workspace", }); expect(connectRes.status).toBe(422); expect(connectRes.body).toMatchObject({ code: "oauth_redirect_origin_unsupported", error: "This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.", }); }); it("uses an authenticated same-origin HTTPS browser request without public URL config", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); const app = createRouteApp( db, boardSessionActor(company.id, "owner"), undefined, { deploymentMode: "authenticated", deploymentExposure: "private", }, ); const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .set("Host", "paperclip.tail123.ts.net") .set("Origin", "https://paperclip.tail123.ts.net") .send({ galleryKey: "slack", name: "Tailscale Slack workspace" }); expect(connectRes.status).toBe(201); expect( new URL(connectRes.body.auth.startUrl).searchParams.get("redirect_uri"), ).toBe("https://paperclip.tail123.ts.net/api/tools/oauth/callback"); }); it("rejects a browser HTTPS origin that does not match the routed request host", async () => { vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); const app = createRouteApp( db, boardSessionActor(company.id, "owner"), undefined, { deploymentMode: "authenticated", deploymentExposure: "private", }, ); const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .set("Host", "paperclip.tail123.ts.net") .set("Origin", "https://evil.example") .send({ galleryKey: "slack", name: "Mismatched Slack workspace" }); expect(connectRes.status).toBe(422); expect(connectRes.body).toMatchObject({ code: "oauth_redirect_origin_unsupported", }); }); it("requires non-viewer board access to start OAuth for active app connections", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp( company.id, { galleryKey: "slack", name: "Slack reauth" }, { actorType: "user", actorId: "operator-user" }, ); await db .update(toolConnections) .set({ status: "active", updatedAt: new Date() }) .where(eq(toolConnections.id, connect.connectionId)); const viewerApp = createRouteApp( db, boardSessionActor(company.id, "viewer", "viewer-user"), ); await request(viewerApp) .post(`/api/tools/oauth/${connect.connectionId}/start`) .send({}) .expect(403); await request(viewerApp) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ galleryKey: "slack", name: "Viewer Slack" }) .expect(403); const operatorActor = boardSessionActor( company.id, "operator", "operator-user", ); const operatorApp = createRouteApp(db, operatorActor); const startRes = await request(operatorApp) .post(`/api/tools/oauth/${connect.connectionId}/start`) .send({}) .expect(200); const state = new URL(startRes.body.authorizationUrl).searchParams.get( "state", ); expect(state).toBeTruthy(); await expect(db.select().from(toolOauthStates)).resolves.toEqual([ expect.objectContaining({ state, connectionId: connect.connectionId, companyId: company.id, createdByActorType: "user", createdByActorId: "operator-user", createdBySessionId: operatorActor.sessionId, }), ]); }); it("lets the retained personal identity owner reconnect without manager configuration access", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); const userId = `personal-oauth-member-${randomUUID()}`; const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp( company.id, { galleryKey: "slack", name: "Legacy personal Slack", grantKind: "user" }, { actorType: "user", actorId: userId }, ); // Older personal rows may retain the user grant without a creator on the // connection. Reconnect belongs to the grant subject, not only a manager. await db .update(toolConnections) .set({ status: "active", createdByUserId: null, updatedAt: new Date(), }) .where(eq(toolConnections.id, connect.connectionId)); await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connect.connectionId, kind: "user", subjectUserId: userId, credentialSecretRefs: [], status: "active", isDefault: false, createdByUserId: userId, }); const memberApp = createRouteApp( db, boardSessionActor(company.id, "operator", userId), ); const start = await request(memberApp) .post(`/api/tools/oauth/${connect.connectionId}/start`) .send({ asCurrentUser: true }) .expect(200); const state = new URL(start.body.authorizationUrl).searchParams.get( "state", )!; await expect(service.peekOAuthState(state)).resolves.toMatchObject({ subjectUserId: userId, }); const otherMemberApp = createRouteApp( db, boardSessionActor(company.id, "operator", `other-${randomUUID()}`), ); await request(otherMemberApp) .post(`/api/tools/oauth/${connect.connectionId}/start`) .send({ asCurrentUser: true }) .expect(403); const viewerApp = createRouteApp( db, boardSessionActor(company.id, "viewer", userId), ); await request(viewerApp) .post(`/api/tools/oauth/${connect.connectionId}/start`) .send({ asCurrentUser: true }) .expect(403); }); it("requires non-viewer board access to finish app activation and bind profiles", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "kv_get", description: "Read a value.", inputSchema: { type: "object", properties: { key: { type: "string" } }, }, annotations: { readOnlyHint: true }, }, ]); const connect = await service.connectGalleryApp( company.id, { link: "https://secure.example/mcp", name: "Viewer finish blocked", credentialValues: { "headers.Authorization": "Bearer imported-token" }, }, { actorType: "user", actorId: "board" }, ); const bindingsBefore = await db .select({ id: toolProfileBindings.id }) .from(toolProfileBindings); const viewerApp = createRouteApp( db, boardSessionActor(company.id, "viewer", "viewer-user"), ); await request(viewerApp) .post( `/api/companies/${company.id}/tools/apps/${connect.connectionId}/finish`, ) .send({ enabledCatalogEntryIds: connect.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [], access: "all_agents", }) .expect(403); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connect.connectionId)); expect(connection.status).toBe("draft"); expect(connection.enabled).toBe(false); await expect( db.select({ id: toolProfileBindings.id }).from(toolProfileBindings), ).resolves.toEqual(bindingsBefore); }); it("binds OAuth callback completion to the initiating board session", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); await grantBoardUser(db, company.id, "oauth-operator", [ "tools:manage_connections", ]); const service = createTestToolAccessService(db); const initiatingActor = boardSessionActor( company.id, "operator", "oauth-operator", ); const connect = await service.connectGalleryApp( company.id, { galleryKey: "slack", name: "Slack bound" }, { actorType: "user", actorId: initiatingActor.userId }, ); const initiatingApp = createRouteApp(db, initiatingActor); const startRes = await request(initiatingApp) .post(`/api/tools/oauth/${connect.connectionId}/start`) .send({}) .expect(200); const state = new URL(startRes.body.authorizationUrl).searchParams.get( "state", )!; const anonymousApp = createRouteApp(db, { type: "none", source: "none" }); await request(anonymousApp) .get("/api/tools/oauth/callback") .query({ state, code: "oauth-code" }) .expect(403); const otherApp = createRouteApp( db, boardSessionActor(company.id, "operator", "other-operator"), ); await request(otherApp) .get("/api/tools/oauth/callback") .query({ state, code: "oauth-code" }) .expect(403); const otherSessionSameUserApp = createRouteApp( db, boardSessionActor( company.id, "operator", "oauth-operator", "other-session", ), ); await request(otherSessionSameUserApp) .get("/api/tools/oauth/callback") .query({ state, code: "oauth-code" }) .expect(403); const downgradedActor = { ...initiatingActor, companyIds: [company.id], memberships: [ { companyId: company.id, membershipRole: "viewer" as const, status: "active", }, ], }; const downgradedApp = createRouteApp(db, downgradedActor); await request(downgradedApp) .get("/api/tools/oauth/callback") .query({ state, code: "oauth-code" }) .expect(403); await db .delete(principalPermissionGrants) .where( and( eq(principalPermissionGrants.companyId, company.id), eq(principalPermissionGrants.principalType, "user"), eq(principalPermissionGrants.principalId, "oauth-operator"), eq( principalPermissionGrants.permissionKey, "tools:manage_connections", ), ), ); await request(initiatingApp) .get("/api/tools/oauth/callback") .query({ state, code: "oauth-code" }) .expect(403); await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "user", principalId: "oauth-operator", permissionKey: "tools:manage_connections", scope: null, grantedByUserId: "owner", }); await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(1); vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { const body = init?.body as URLSearchParams; expect(body.get("grant_type")).toBe("authorization_code"); expect(body.get("code")).toBe("oauth-code"); return { ok: true, json: async () => ({ ok: true, access_token: "bound-access-token", refresh_token: "bound-refresh-token", expires_in: 3600, token_type: "Bearer", }), } as Response; } if (href === "https://mcp.slack.com/mcp") { expect(init?.headers).toEqual( expect.objectContaining({ Authorization: "Bearer bound-access-token", }), ); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "search_messages", annotations: { readOnlyHint: true } }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); await request(initiatingApp) .get("/api/tools/oauth/callback") .query({ state, code: "oauth-code" }) .expect(200); await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0); }); it("discovers Notion MCP OAuth metadata, registers one public client, and reuses it", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "notion", name: "Notion DCR", }); const redirectUri = "https://paperclip-dev.tail29c1aa.ts.net/api/tools/oauth/callback"; const registrationBodies: Array> = []; const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url, init) => { const href = String(url); if ( href === "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp" ) { return mcpHttpResponse({ authorization_servers: ["https://mcp.notion.com"], scopes_supported: ["default"], }); } if ( href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { return mcpHttpResponse({ issuer: "https://mcp.notion.com", authorization_endpoint: "https://mcp.notion.com/authorize", token_endpoint: "https://mcp.notion.com/token", registration_endpoint: "https://mcp.notion.com/register", code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], }); } if (href === "https://mcp.notion.com/register") { expect(init?.method).toBe("POST"); registrationBodies.push( JSON.parse(String(init?.body)) as Record, ); return mcpHttpResponse({ client_id: "notion-dcr-client", client_secret: "notion-dcr-secret", redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", }); } throw new Error(`unexpected fetch ${href}`); }); const [first, concurrent] = await Promise.all([ service.startOAuth(company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }), service.startOAuth(company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }), ]); expect(new URL(first.authorizationUrl).origin).toBe( "https://mcp.notion.com", ); expect( new URL(first.authorizationUrl).searchParams.get("scope"), ).toBeNull(); expect( new URL(concurrent.authorizationUrl).searchParams.get("client_id"), ).toBe("notion-dcr-client"); expect(registrationBodies).toEqual([ { client_name: "Paperclip (paperclip-dev.tail29c1aa.ts.net)", redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", // PAP-17087: Paperclip's callback is a server-side HTTPS endpoint, so // registration must declare a `web` client rather than let the // authorization server apply native-client redirect rules. application_type: "web", }, ]); fetchMock.mockClear(); const reused = await service.startOAuth( company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }, ); expect(new URL(reused.authorizationUrl).searchParams.get("client_id")).toBe( "notion-dcr-client", ); expect(fetchMock).not.toHaveBeenCalled(); await expect( service.startOAuth(company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, scopes: ["unreviewed:admin"], }), ).rejects.toMatchObject({ status: 400, details: { code: "oauth_scope_widening_rejected", scopes: ["unreviewed:admin"], }, }); expect(fetchMock).not.toHaveBeenCalled(); const [damagedDcrConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); await db .update(toolConnections) .set({ config: { ...damagedDcrConnection.config, oauth: { ...(damagedDcrConnection.config.oauth as Record), clientRegistrationSource: "manual", }, }, }) .where(eq(toolConnections.id, connected.connectionId)); fetchMock.mockClear(); const repaired = await service.startOAuth( company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }, ); expect(repaired.registrationSource).toBe("dcr"); expect( fetchMock.mock.calls.filter( ([url]) => String(url) === "https://mcp.notion.com/register", ), ).toHaveLength(1); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect(connection).toMatchObject({ ownership: "dcr" }); expect(connection.config).toMatchObject({ oauth: { provider: "notion", clientId: "notion-dcr-client", clientRegistrationSource: "dcr", clientTokenEndpointAuthMethod: "none", clientRedirectUri: redirectUri, registrationUrl: "https://mcp.notion.com/register", scopes: [], }, }); expect(connection.credentialSecretRefs).toEqual([ expect.objectContaining({ configPath: "oauth.client_secret", required: false, }), ]); expect(JSON.stringify(connection.config)).not.toContain( "notion-dcr-secret", ); }); it("supports confidential DCR clients without exposing their registration secret", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SUPABASE_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SUPABASE_CLIENT_SECRET", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "supabase", connectionMethodKey: "mcp-oauth", name: "Supabase confidential DCR", configValues: { projectRef: "supabaseproject12345", readOnly: true, features: "database", }, }); const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url, init) => { const href = String(url); if ( href === "https://mcp.supabase.com/.well-known/oauth-protected-resource/mcp" ) { return mcpHttpResponse({ resource: "https://mcp.supabase.com/mcp", authorization_servers: ["https://api.supabase.com"], }); } if ( href === "https://api.supabase.com/.well-known/oauth-authorization-server" ) { return mcpHttpResponse({ issuer: "https://api.supabase.com", authorization_endpoint: "https://api.supabase.com/v1/oauth/authorize", token_endpoint: "https://api.supabase.com/v1/oauth/token", registration_endpoint: "https://api.supabase.com/platform/oauth/apps/register", code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: [ "client_secret_basic", "client_secret_post", ], }); } if (href === "https://api.supabase.com/platform/oauth/apps/register") { const requestBody = JSON.parse(String(init?.body)) as Record< string, unknown >; expect(requestBody.token_endpoint_auth_method).toBe( "client_secret_basic", ); return mcpHttpResponse({ client_id: "supabase-dcr-client", client_secret: "supabase-dcr-secret", redirect_uris: [redirectUri], // Supabase's live DCR response intentionally omits the request // metadata it accepted and returns only client material + redirects. client_secret_expires_at: 0, }); } if (href === "https://api.supabase.com/v1/oauth/token") { const headers = init?.headers as Record; expect(headers.Authorization).toBe( `Basic ${Buffer.from("supabase-dcr-client:supabase-dcr-secret").toString("base64")}`, ); const body = init?.body as URLSearchParams; expect(body.get("client_id")).toBeNull(); expect(body.get("client_secret")).toBeNull(); expect(body.get("code")).toBe("supabase-code"); return mcpHttpResponse({ access_token: "supabase-access-token", refresh_token: "supabase-refresh-token", expires_in: 3600, token_type: "Bearer", }); } if ( href === "https://mcp.supabase.com/mcp?project_ref=supabaseproject12345&read_only=true&features=database" ) { expect(init?.headers).toEqual( expect.objectContaining({ Authorization: "Bearer supabase-access-token", }), ); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "list_tables", annotations: { readOnlyHint: true } }, { name: "execute_sql", annotations: { readOnlyHint: false } }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }, ); const state = new URL(started.authorizationUrl).searchParams.get("state"); expect(state).toBeTruthy(); const completed = await service.completeOAuthCallback({ state: state!, code: "supabase-code", redirectUri, actor: { actorType: "user", actorId: "board" }, }); expect(fetchMock).toHaveBeenCalled(); expect(completed.actions.readOnly).toEqual([ expect.objectContaining({ toolName: "list_tables", riskLevel: "read" }), ]); await expect( db .select() .from(toolPolicies) .where(eq(toolPolicies.companyId, company.id)), ).resolves.toEqual([]); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect(connection.config).toMatchObject({ oauth: { clientId: "supabase-dcr-client", clientRegistrationSource: "dcr", clientTokenEndpointAuthMethod: "client_secret_basic", }, }); expect(connection.credentialSecretRefs).toEqual( expect.arrayContaining([ expect.objectContaining({ configPath: "oauth.client_secret" }), expect.objectContaining({ configPath: "oauth.access_token" }), expect.objectContaining({ configPath: "oauth.refresh_token" }), ]), ); expect(JSON.stringify(connection.config)).not.toContain( "supabase-dcr-secret", ); expect(JSON.stringify(completed)).not.toContain("supabase-dcr-secret"); }); it("preserves the provider's DCR client-auth ordering for Miro token exchange", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_SECRET", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "miro", connectionMethodKey: "mcp-oauth", name: "Miro DCR", }); const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if ( href === "https://mcp.miro.com/.well-known/oauth-protected-resource" ) { return mcpHttpResponse({ resource: "https://mcp.miro.com/", authorization_servers: ["https://mcp.miro.com/"], }); } if ( href === "https://mcp.miro.com/.well-known/oauth-authorization-server" ) { return mcpHttpResponse({ issuer: "https://mcp.miro.com/", authorization_endpoint: "https://mcp.miro.com/authorize", token_endpoint: "https://mcp.miro.com/token", registration_endpoint: "https://mcp.miro.com/register", grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: [ "client_secret_post", "client_secret_basic", ], }); } if (href === "https://mcp.miro.com/register") { const requestBody = JSON.parse(String(init?.body)) as Record< string, unknown >; expect(requestBody.token_endpoint_auth_method).toBe( "client_secret_post", ); return mcpHttpResponse({ client_id: "miro-dcr-client", client_secret: "miro-dcr-secret", redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "client_secret_post", }); } if (href === "https://mcp.miro.com/token") { const headers = new Headers(init?.headers); const body = init?.body as URLSearchParams; expect(headers.get("authorization")).toBeNull(); expect(body.get("client_id")).toBe("miro-dcr-client"); expect(body.get("client_secret")).toBe("miro-dcr-secret"); expect(body.get("code")).toBe("miro-code"); return mcpHttpResponse({ access_token: "miro-access-token", refresh_token: "miro-refresh-token", expires_in: 3600, token_type: "Bearer", }); } if (href === "https://mcp.miro.com/") { expect(new Headers(init?.headers).get("authorization")).toBe( "Bearer miro-access-token", ); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [{ name: "whoami", annotations: { readOnlyHint: true } }], }, }); } throw new Error(`unexpected fetch ${href}`); }); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }, ); const state = new URL(started.authorizationUrl).searchParams.get("state"); expect(state).toBeTruthy(); const completed = await service.completeOAuthCallback({ state: state!, code: "miro-code", redirectUri, actor: { actorType: "user", actorId: "board" }, }); expect(completed.actions.readOnly).toEqual([ expect.objectContaining({ toolName: "whoami", riskLevel: "read" }), ]); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect(connection.config).toMatchObject({ oauth: { clientTokenEndpointAuthMethod: "client_secret_post" }, }); expect(JSON.stringify(connection.config)).not.toContain("miro-dcr-secret"); expect(JSON.stringify(completed)).not.toContain("miro-dcr-secret"); }); it("accepts provider-added DCR grants without adopting them", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_HUGGING_FACE_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_HUGGING_FACE_CLIENT_SECRET", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "hugging-face", connectionMethodKey: "mcp-oauth", name: "Hugging Face DCR", }); const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if ( href === "https://huggingface.co/.well-known/oauth-protected-resource/mcp?login&gradio=none" ) { return mcpHttpResponse({ resource: "https://huggingface.co/mcp?login&gradio=none", authorization_servers: ["https://huggingface.co"], }); } if ( href === "https://huggingface.co/.well-known/oauth-authorization-server" ) { return mcpHttpResponse({ issuer: "https://huggingface.co", authorization_endpoint: "https://huggingface.co/oauth/authorize", token_endpoint: "https://huggingface.co/oauth/token", registration_endpoint: "https://huggingface.co/oauth/register", code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: [ "client_secret_basic", "client_secret_post", ], }); } if (href === "https://huggingface.co/oauth/register") { const requestBody = JSON.parse(String(init?.body)) as Record< string, unknown >; expect(requestBody.grant_types).toEqual([ "authorization_code", "refresh_token", ]); expect(requestBody.response_types).toEqual(["code"]); return mcpHttpResponse({ client_id: "hugging-face-dcr-client", client_secret: "hugging-face-dcr-secret", redirect_uris: [redirectUri], grant_types: [ "urn:ietf:params:oauth:grant-type:device_code", "authorization_code", "refresh_token", ], response_types: ["code", "token"], token_endpoint_auth_method: "client_secret_basic", client_secret_expires_at: 0, }); } throw new Error(`unexpected fetch ${href}`); }); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }, ); expect(new URL(started.authorizationUrl).origin).toBe( "https://huggingface.co", ); expect( new URL(started.authorizationUrl).searchParams.get("response_type"), ).toBe("code"); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect(connection.config).toMatchObject({ oauth: { clientId: "hugging-face-dcr-client", clientRegistrationSource: "dcr", clientTokenEndpointAuthMethod: "client_secret_basic", }, }); expect(JSON.stringify(connection.config)).not.toContain("device_code"); expect(JSON.stringify(connection.config)).not.toContain( "hugging-face-dcr-secret", ); }); it("does not request refresh-token registration from a provider that explicitly omits it", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CODA_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CODA_CLIENT_SECRET", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "coda", connectionMethodKey: "mcp-oauth", name: "Coda DCR", }); const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if ( href === "https://coda.io/.well-known/oauth-protected-resource/apis/mcp" ) { return mcpHttpResponse({ resource: "https://coda.io/apis/mcp", authorization_servers: ["https://coda.io"], }); } if (href === "https://coda.io/.well-known/oauth-authorization-server") { return mcpHttpResponse({ issuer: "https://coda.io", authorization_endpoint: "https://coda.io/v4/api/oauth2/authorize", token_endpoint: "https://coda.io/v4/api/oauth2/token", registration_endpoint: "https://coda.io/v4/api/oauth2/register", grant_types_supported: ["authorization_code"], response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["client_secret_post"], }); } if (href === "https://coda.io/v4/api/oauth2/register") { const requestBody = JSON.parse(String(init?.body)) as Record< string, unknown >; expect(requestBody.grant_types).toEqual(["authorization_code"]); return mcpHttpResponse({ client_id: "coda-dcr-client", client_secret: "coda-dcr-secret", redirect_uris: [redirectUri], grant_types: ["authorization_code"], response_types: ["code"], token_endpoint_auth_method: "client_secret_post", }); } throw new Error(`unexpected fetch ${href}`); }); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }, ); expect(new URL(started.authorizationUrl).origin).toBe("https://coda.io"); }); it("normalizes a public DCR client's zero secret expiry when no secret was issued", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIXPANEL_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIXPANEL_CLIENT_SECRET", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "mixpanel", connectionMethodKey: "mcp-oauth", name: "Mixpanel public DCR", }); const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { const href = String(url); if ( href === "https://mcp.mixpanel.com/.well-known/oauth-protected-resource/mcp" ) { return mcpHttpResponse({ resource: "https://mcp.mixpanel.com/mcp", authorization_servers: ["https://mcp.mixpanel.com/mcp"], }); } if ( href === "https://mcp.mixpanel.com/.well-known/oauth-authorization-server/mcp" ) { return mcpHttpResponse({ issuer: "https://mcp.mixpanel.com/mcp", authorization_endpoint: "https://mixpanel.com/oauth/authorize", token_endpoint: "https://mixpanel.com/oauth/token/", registration_endpoint: "https://mixpanel.com/oauth/mcp/register/", code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: [ "none", "client_secret_basic", ], }); } if (href === "https://mixpanel.com/oauth/mcp/register/") { return mcpHttpResponse({ client_id: "mixpanel-public-client", client_id_issued_at: 1_787_773_729, client_secret_expires_at: 0, redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", }); } throw new Error(`unexpected fetch ${href}`); }); const started = await service.startOAuth( company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }, ); expect(new URL(started.authorizationUrl).origin).toBe( "https://mixpanel.com", ); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect(connection.config).toMatchObject({ oauth: { clientId: "mixpanel-public-client", clientSecretExpiresAt: null, clientTokenEndpointAuthMethod: "none", }, }); expect(connection.credentialSecretRefs).not.toEqual( expect.arrayContaining([ expect.objectContaining({ configPath: "oauth.client_secret" }), ]), ); }); it("stores a curated customer-owned OAuth client without exposing its secret", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "asana", name: "Asana own app", oauthClient: { clientId: "asana-customer-client", clientSecret: "asana-customer-secret", }, }); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect(connection.config).toMatchObject({ sourceTemplateKey: "asana", oauth: { clientId: "asana-customer-client", clientRegistrationSource: "manual", clientCompanyId: company.id, }, }); expect(connection.credentialSecretRefs).toEqual([ expect.objectContaining({ configPath: "oauth.client_secret", required: false, }), ]); expect(JSON.stringify(connection.config)).not.toContain( "asana-customer-secret", ); expect(JSON.stringify(connected)).not.toContain("asana-customer-secret"); const resumed = await service.connectGalleryApp(company.id, { galleryKey: "asana", name: "Asana own app", resumeConnectionId: connected.connectionId, oauthClient: { clientId: "asana-customer-client", }, }); const [resumedConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, resumed.connectionId)); expect(resumedConnection.credentialSecretRefs).toEqual( connection.credentialSecretRefs, ); }); it("does not retain a customer OAuth secret when the client id changes", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "asana", name: "Asana own app", oauthClient: { clientId: "asana-first-client", clientSecret: "asana-first-secret", }, }); const resumed = await service.connectGalleryApp(company.id, { galleryKey: "asana", name: "Asana own app", resumeConnectionId: connected.connectionId, oauthClient: { clientId: "asana-second-client", }, }); const [resumedConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, resumed.connectionId)); expect(resumedConnection.credentialSecretRefs).not.toEqual( expect.arrayContaining([ expect.objectContaining({ configPath: "oauth.client_secret" }), ]), ); }); it("retains an encrypted API key when the same draft method resumes", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "search_memories", annotations: { readOnlyHint: true } }, ]); const connected = await service.connectGalleryApp(company.id, { galleryKey: "mem0", name: "Mem0 for the company", credentialValues: { "credentials.authorization": "mem0-test-secret", }, }); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); await db .update(toolConnections) .set({ status: "draft", enabled: false }) .where(eq(toolConnections.id, connected.connectionId)); const resumed = await service.connectGalleryApp(company.id, { galleryKey: "mem0", name: "Mem0 for the company", resumeConnectionId: connected.connectionId, }); const [resumedConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, resumed.connectionId)); expect(resumedConnection.credentialSecretRefs).toEqual( connection.credentialSecretRefs, ); expect(resumedConnection.credentialRefs).toEqual(connection.credentialRefs); }); it.each([ [ "a confidential token endpoint auth method", { client_id: "notion-dcr-client", token_endpoint_auth_method: "client_secret_basic", }, "token_endpoint_auth_method", ], [ "a different redirect URI", { client_id: "notion-dcr-client", redirect_uris: ["https://attacker.example/callback"], }, "redirect_uris", ], [ "a reduced grant set", { client_id: "notion-dcr-client", grant_types: ["authorization_code"] }, "grant_types", ], [ "different response types", { client_id: "notion-dcr-client", response_types: ["token"] }, "response_types", ], ["an oversized client id", { client_id: "x".repeat(4_097) }, "client_id"], ])( "rejects DCR responses that return %s", async (_label, registrationResponse, field) => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_SECRET", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "notion", name: `Notion invalid DCR ${field}`, }); const redirectUri = "https://paperclip-dev.tail29c1aa.ts.net/api/tools/oauth/callback"; vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { const href = String(url); if ( href === "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp" ) { return mcpHttpResponse({ authorization_servers: ["https://mcp.notion.com"], }); } if ( href === "https://mcp.notion.com/.well-known/oauth-authorization-server" ) { return mcpHttpResponse({ authorization_endpoint: "https://mcp.notion.com/authorize", token_endpoint: "https://mcp.notion.com/token", registration_endpoint: "https://mcp.notion.com/register", code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], }); } if (href === "https://mcp.notion.com/register") { return mcpHttpResponse({ client_id: "notion-dcr-client", redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none", ...registrationResponse, }); } throw new Error(`unexpected fetch ${href}`); }); await expect( service.startOAuth(company.id, connected.connectionId, { redirectUri, actor: { actorType: "user", actorId: "board" }, }), ).rejects.toMatchObject({ status: 502, details: { code: "oauth_dcr_response_invalid", field, }, }); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connected.connectionId)); expect(connection.ownership).not.toBe("dcr"); expect( (connection.config.oauth as Record | undefined) ?.clientId, ).toBeUndefined(); }, ); it("fails fast when Notion DCR is attempted from a non-loopback HTTP origin", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "notion", name: "Notion invalid origin", }); const fetchMock = vi.spyOn(globalThis, "fetch"); await expect( service.startOAuth(company.id, connected.connectionId, { redirectUri: "http://paperclip-dev:3100/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }), ).rejects.toMatchObject({ status: 422, message: "This provider requires an HTTPS or loopback origin. Configure TLS before connecting.", details: expect.objectContaining({ code: "oauth_redirect_origin_unsupported", docsPath: "docs/deploy", }), }); expect(fetchMock).not.toHaveBeenCalled(); }); it("leases rotating OAuth refresh tokens across service instances before concurrent remote app calls", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const concurrentService = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack refresh", }); const start = await service.startOAuth(company.id, connect.connectionId, { redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); const state = new URL(start.authorizationUrl).searchParams.get("state")!; let refreshCallCount = 0; vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { const body = init?.body as URLSearchParams; if (body.get("grant_type") === "authorization_code") { return { ok: true, json: async () => ({ ok: true, access_token: "old-access-token", refresh_token: "refresh-token", expires_in: 3600, token_type: "Bearer", }), } as Response; } expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh-token"); refreshCallCount += 1; await new Promise((resolve) => setTimeout(resolve, 10)); return { ok: true, json: async () => ({ ok: true, access_token: "new-access-token", refresh_token: "new-refresh-token", expires_in: 3600, token_type: "Bearer", }), } as Response; } if (href === "https://mcp.slack.com/mcp") { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "search_messages", annotations: { readOnlyHint: true } }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); await service.completeOAuthCallback({ state, code: "oauth-code", redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); const [connected] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connect.connectionId)); await db .update(toolConnections) .set({ config: { ...connected.config, oauth: { ...(connected.config.oauth as Record), expiresAt: "2000-01-01T00:00:00.000Z", }, }, }) .where(eq(toolConnections.id, connect.connectionId)); const [health, concurrentHealth] = await Promise.all([ service.checkHealth(connect.connectionId), concurrentService.checkHealth(connect.connectionId), ]); expect(health.connection.healthStatus).toBe("ok"); expect(concurrentHealth.connection.healthStatus).toBe("ok"); expect(refreshCallCount).toBe(1); const fetchCalls = vi.mocked(globalThis.fetch).mock.calls; const mcpCalls = fetchCalls.filter( ([url]) => String(url) === "https://mcp.slack.com/mcp", ); expect(mcpCalls.at(-1)?.[1]?.headers).toEqual( expect.objectContaining({ Authorization: "Bearer new-access-token" }), ); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connect.connectionId)); expect( Date.parse( String((connection.config.oauth as { expiresAt: string }).expiresAt), ), ).toBeGreaterThan(Date.now()); const refreshRef = connection.credentialSecretRefs.find( (ref) => ref.configPath === "oauth.refresh_token", )!; const refreshVersions = await db .select() .from(companySecretVersions) .where(eq(companySecretVersions.secretId, refreshRef.secretId)); expect(refreshVersions).toHaveLength(2); expect(refreshVersions.map((version) => version.status).sort()).toEqual([ "current", "previous", ]); const credentialAccessEvents = await db .select() .from(secretAccessEvents) .where( and( eq(secretAccessEvents.companyId, company.id), eq(secretAccessEvents.consumerId, connect.connectionId), ), ); expect(credentialAccessEvents).toEqual( expect.arrayContaining([ expect.objectContaining({ configPath: "oauth.refresh_token", outcome: "success", }), expect.objectContaining({ configPath: "credentials.oauth.access_token", outcome: "success", }), ]), ); }); it("treats invalid_grant as terminal without replaying a rotated refresh token", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack invalid grant", }); const start = await service.startOAuth(company.id, connect.connectionId, { redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); const state = new URL(start.authorizationUrl).searchParams.get("state")!; const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { return mcpHttpResponse({ ok: true, access_token: "expired-access-token", refresh_token: "single-use-refresh-token", expires_in: 3600, token_type: "Bearer", }); } if (href === "https://mcp.slack.com/mcp") { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] }, }); } throw new Error(`unexpected fetch ${href}`); }); await service.completeOAuthCallback({ state, code: "oauth-code", redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); const [connected] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connect.connectionId)); await db .update(toolConnections) .set({ config: { ...connected.config, oauth: { ...(connected.config.oauth as Record), expiresAt: "2000-01-01T00:00:00.000Z", }, }, }) .where(eq(toolConnections.id, connect.connectionId)); let refreshCallCount = 0; fetchMock.mockImplementation(async (url, init) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { const body = init?.body as URLSearchParams; expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("single-use-refresh-token"); refreshCallCount += 1; await new Promise((resolve) => setTimeout(resolve, 10)); return { ok: false, status: 400, headers: { get: () => null }, json: async () => ({ error: "invalid_grant", error_description: "Refresh token was already used", }), } as unknown as Response; } throw new Error(`unexpected fetch ${href}`); }); const results = await Promise.allSettled([ service.checkHealth(connect.connectionId), service.checkHealth(connect.connectionId), ]); expect(results).toEqual([ expect.objectContaining({ status: "rejected" }), expect.objectContaining({ status: "rejected" }), ]); for (const result of results) { expect(result.status === "rejected" ? result.reason : null).toMatchObject( { details: expect.objectContaining({ code: "oauth_reauthorization_required", }), }, ); } expect(refreshCallCount).toBe(1); const [reauthorizationRequired] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connect.connectionId)); expect(reauthorizationRequired).toMatchObject({ status: "draft", enabled: false, healthStatus: "error", }); expect( reauthorizationRequired.credentialSecretRefs.map((ref) => ref.configPath), ).not.toContain("oauth.access_token"); expect( reauthorizationRequired.credentialSecretRefs.map((ref) => ref.configPath), ).not.toContain("oauth.refresh_token"); expect( reauthorizationRequired.credentialRefs.map((ref) => ref.name), ).not.toContain("oauth.access_token"); }); it("does not disable a connection when invalid_grant used a superseded refresh-token version", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack stale invalid grant", }); const start = await service.startOAuth(company.id, connect.connectionId, { redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); const state = new URL(start.authorizationUrl).searchParams.get("state")!; const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { return mcpHttpResponse({ ok: true, access_token: "expired-access-token", refresh_token: "submitted-refresh-token", expires_in: 3600, token_type: "Bearer", }); } if (href === "https://mcp.slack.com/mcp") { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [] }, }); } throw new Error(`unexpected fetch ${href}`); }); await service.completeOAuthCallback({ state, code: "oauth-code", redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); const [connected] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connect.connectionId)); const refreshRef = connected.credentialSecretRefs.find( (ref) => ref.configPath === "oauth.refresh_token", )!; await db .update(toolConnections) .set({ config: { ...connected.config, oauth: { ...(connected.config.oauth as Record), expiresAt: "2000-01-01T00:00:00.000Z", }, }, }) .where(eq(toolConnections.id, connect.connectionId)); fetchMock.mockImplementation(async (url, init) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { const body = init?.body as URLSearchParams; expect(body.get("refresh_token")).toBe("submitted-refresh-token"); await secretService(db).rotate(refreshRef.secretId, { value: "newer-refresh-token", }); return { ok: false, status: 400, headers: { get: () => null }, json: async () => ({ error: "invalid_grant" }), } as unknown as Response; } throw new Error(`unexpected fetch ${href}`); }); await expect( service.checkHealth(connect.connectionId), ).rejects.toMatchObject({ status: 502, details: expect.objectContaining({ code: "oauth_refresh_superseded" }), }); const [preserved] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connect.connectionId)); expect(preserved).toMatchObject({ status: "active", enabled: true }); expect(preserved.credentialSecretRefs.map((ref) => ref.configPath)).toEqual( expect.arrayContaining(["oauth.access_token", "oauth.refresh_token"]), ); expect( (preserved.config.oauth as Record).refreshLease, ).toBeUndefined(); }); it("fails closed instead of replaying a refresh token after an abandoned lease", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); const service = createTestToolAccessService(db); const fixture = await createOAuthConnection(db, company.id); const refreshSecret = await secretService(db).create(company.id, { provider: "local_encrypted", name: `OAuth refresh ${randomUUID()}`, key: `oauth.refresh.${randomUUID()}`, value: "possibly-consumed-refresh-token", }); await db.insert(companySecretBindings).values({ companyId: company.id, secretId: refreshSecret.id, targetType: "tool_connection", targetId: fixture.connection.id, configPath: "oauth.refresh_token", }); await db .update(toolConnections) .set({ config: { ...fixture.connection.config, oauth: { ...(fixture.connection.config.oauth as Record), expiresAt: "2000-01-01T00:00:00.000Z", refreshLease: { id: "abandoned-refresh", expiresAt: "2000-01-01T00:00:00.000Z", }, }, }, credentialSecretRefs: [ ...fixture.connection.credentialSecretRefs, { secretId: refreshSecret.id, versionSelector: "latest", configPath: "oauth.refresh_token", required: false, label: "OAuth refresh token", }, ], }) .where(eq(toolConnections.id, fixture.connection.id)); const fetchMock = vi.spyOn(globalThis, "fetch"); await expect( service.checkHealth(fixture.connection.id), ).rejects.toMatchObject({ status: 502, details: expect.objectContaining({ code: "oauth_refresh_outcome_unknown", }), }); expect(fetchMock).not.toHaveBeenCalled(); }); it("uses OAuth client credentials for shared machine-to-machine MCP connections", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_M2M_CLIENT_ID", "m2m-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_M2M_CLIENT_SECRET", "m2m-client-secret"); const company = await createCompany(db); const service = createTestToolAccessService(db); const connection = await service.createConnection(company.id, { name: "Machine OAuth", transport: "mcp_remote", config: { url: "https://m2m.example.test/mcp", oauth: { provider: "m2m", tokenUrl: "https://m2m.example.test/oauth/token", grantType: "client_credentials", scopes: ["tools.read"], }, }, enabled: true, status: "active", }); const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url, init) => { const href = String(url); if (href === "https://m2m.example.test/oauth/token") { const body = init?.body as URLSearchParams; expect(body.get("grant_type")).toBe("client_credentials"); expect(body.get("client_id")).toBe("m2m-client-id"); expect(body.get("client_secret")).toBe("m2m-client-secret"); expect(body.get("scope")).toBe("tools.read"); return { ok: true, json: async () => ({ access_token: "m2m-access-token", expires_in: 3600, token_type: "Bearer", }), } as Response; } if (href === "https://m2m.example.test/mcp") { expect(init?.headers).toEqual( expect.objectContaining({ Authorization: "Bearer m2m-access-token", }), ); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "machine_read", annotations: { readOnlyHint: true } }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); const health = await service.checkHealth(connection.id, { actorType: "system", actorId: "health-check", }); expect(health.connection.healthStatus).toBe("ok"); expect(fetchMock).toHaveBeenCalledTimes(2); const [updated] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connection.id)); expect(updated.credentialSecretRefs).toEqual([ expect.objectContaining({ configPath: "oauth.access_token", label: "OAuth access token", }), ]); expect(updated.credentialRefs).toEqual([ expect.objectContaining({ name: "oauth.access_token", key: "Authorization", prefix: "Bearer ", }), ]); expect(JSON.stringify(updated.config)).not.toContain("m2m-access-token"); }); it("fails expired OAuth credentials without a refresh token and returns reconnect links", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret", ); const company = await createCompany(db); await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack no refresh", }); const start = await service.startOAuth(company.id, connect.connectionId, { redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); const state = new URL(start.authorizationUrl).searchParams.get("state")!; const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url) => { const href = String(url); if (href === "https://slack.com/api/oauth.v2.access") { return { ok: true, json: async () => ({ ok: true, access_token: "access-without-refresh", expires_in: 3600, token_type: "Bearer", }), } as Response; } if (href === "https://mcp.slack.com/mcp") { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "search_messages", annotations: { readOnlyHint: true }, }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); await service.completeOAuthCallback({ state, code: "oauth-code", redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); const [connected] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connect.connectionId)); await db .update(toolConnections) .set({ config: { ...connected.config, oauth: { ...(connected.config.oauth as Record), expiresAt: "2000-01-01T00:00:00.000Z", }, }, credentialSecretRefs: connected.credentialSecretRefs.filter( (ref) => ref.configPath !== "oauth.refresh_token", ), }) .where(eq(toolConnections.id, connect.connectionId)); fetchMock.mockClear(); await expect( service.checkHealth(connect.connectionId, { actorType: "user", actorId: "board", }), ).rejects.toMatchObject({ status: 502, details: expect.objectContaining({ code: "oauth_refresh_missing", setupUrl: `/apps/${connect.connectionId}/permissions`, reconnectUrl: `/apps/${connect.connectionId}/permissions`, connection: expect.objectContaining({ healthStatus: "failed" }), }), }); expect(fetchMock).not.toHaveBeenCalled(); const auditRows = await db .select() .from(toolAccessAuditEvents) .where( eq( toolAccessAuditEvents.action, "tool_connection.credential_resolution", ), ); const audit = auditRows.find((row) => row.outcome === "failure"); expect(audit).toMatchObject({ outcome: "failure", reasonCode: "oauth_refresh_missing", }); expect(JSON.stringify(audit)).not.toContain("access-without-refresh"); }); it("returns a callback error when the provider rejects sign-in", async () => { const company = await createCompany(db); const app = createRouteApp( db, boardSessionActor(company.id, "operator", "operator-user"), ); const res = await request(app) .get("/api/tools/oauth/callback") .query({ error: "access_denied", error_description: "User declined" }); expect(res.status).toBe(400); }); it("aggregates app connections needing attention through the board route", async () => { const company = await createCompany(db); const app = createRouteApp(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: `Attention app ${randomUUID()}`, type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: `Attention connection ${randomUUID()}`, uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, transportConfig: { url: "https://fixture.example/mcp" }, healthStatus: "error", healthMessage: "Token revoked.", }) .returning(); const [ignoredConnection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: `Healthy connection ${randomUUID()}`, uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://healthy.example/mcp" }, transportConfig: { url: "https://healthy.example/mcp" }, healthStatus: "ok", }) .returning(); const [catalogEntry] = await db .insert(toolCatalogEntries) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "send_email", toolName: "send_email", riskLevel: "write", isWrite: true, status: "quarantined", versionHash: "v1", schemaHash: "s1", quarantineReason: "pending_review", quarantinedAt: new Date(), }) .returning(); await db.insert(toolCatalogEntries).values({ companyId: company.id, applicationId: application.id, connectionId: ignoredConnection.id, name: "search", toolName: "search", riskLevel: "read", isReadOnly: true, status: "active", versionHash: "v1", schemaHash: "s1", }); const [invocation] = await db .insert(toolInvocations) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email", status: "awaiting_approval", approvalState: "pending", }) .returning(); await db.insert(toolActionRequests).values({ companyId: company.id, invocationId: invocation.id, status: "pending", canonicalArgumentsHash: "args-hash", canonicalArgumentsSummary: { summary: "redacted", redactedFields: [] }, signedArguments: signToolArguments({ invocationId: invocation.id, toolName: invocation.toolName, canonicalArguments: canonicalToolArguments({ redacted: true }), signingSecret: "attention-test-secret", }), }); const res = await request(app).get( `/api/companies/${company.id}/tools/apps/attention`, ); expect(res.status).toBe(200); expect(res.body.totals).toMatchObject({ connections: 1, health: 1, quarantinedCatalogEntries: 1, pendingActionRequests: 1, }); expect(res.body.apps).toEqual([ expect.objectContaining({ connection: expect.objectContaining({ id: connection.id, healthStatus: "error", }), healthNeedsAttention: true, quarantinedCatalogEntryCount: 1, pendingActionRequestCount: 1, reasons: [ "health", "quarantined_catalog_entries", "pending_action_requests", ], }), ]); }); it("cancels invalid-signature pending action requests but keeps unsigned in-flight ones out of the review queue without cancelling them", async () => { vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", "current-secret"); const company = await createCompany(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: `Action review app ${randomUUID()}`, type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: `Action review connection ${randomUUID()}`, uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, }) .returning(); const [catalogEntry] = await db .insert(toolCatalogEntries) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "kv_set", toolName: "kv_set", title: "KV Set", riskLevel: "write", isWrite: true, status: "active", versionHash: "v1", schemaHash: "s1", }) .returning(); const canonicalArguments = canonicalToolArguments({ key: "alpha", value: "one", }); const invocationValues = [1, 2, 3, 4].map(() => ({ companyId: company.id, applicationId: application.id, connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "kv_set", argumentsHash: "args-hash", argumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length, }, policyDecision: "require_approval" as const, approvalState: "pending" as const, status: "awaiting_approval" as const, })); const [ validInvocation, missingSignatureInvocation, staleMissingSignatureInvocation, oldSecretInvocation, ] = await db.insert(toolInvocations).values(invocationValues).returning(); const validSignedArguments = signToolArguments({ invocationId: validInvocation.id, toolName: validInvocation.toolName, canonicalArguments, signingSecret: "current-secret", }); const oldSecretSignedArguments = signToolArguments({ invocationId: oldSecretInvocation.id, toolName: oldSecretInvocation.toolName, canonicalArguments, signingSecret: "old-secret", }); const [ validRequest, missingSignatureRequest, staleMissingSignatureRequest, oldSecretRequest, ] = await db .insert(toolActionRequests) .values([ { companyId: company.id, invocationId: validInvocation.id, status: "pending", canonicalArgumentsHash: "args-hash", canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length, }, signedArguments: validSignedArguments, }, { companyId: company.id, invocationId: missingSignatureInvocation.id, status: "pending", canonicalArgumentsHash: "args-hash", canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length, }, signedArguments: null, }, { companyId: company.id, invocationId: staleMissingSignatureInvocation.id, status: "pending", canonicalArgumentsHash: "args-hash", canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length, }, signedArguments: null, createdAt: new Date(Date.now() - 3 * 60 * 1000), }, { companyId: company.id, invocationId: oldSecretInvocation.id, status: "pending", canonicalArgumentsHash: "args-hash", canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length, }, signedArguments: oldSecretSignedArguments, }, ]) .returning(); const list = await createTestToolAccessService(db).listActionRequests( company.id, "pending", ); const rows = await db.select().from(toolActionRequests); const statusById = new Map(rows.map((row) => [row.id, row.status])); expect(list.map((item) => item.request.id)).toEqual([validRequest.id]); expect(statusById.get(validRequest.id)).toBe("pending"); // An unsigned request is still being created; the read hides it but keeps it // pending, so the creator can finish signing and the later approve succeeds. expect(statusById.get(missingSignatureRequest.id)).toBe("pending"); // If the creator never finishes signing, Review retires the stale orphan // instead of leaving a permanent badge for a request no human can approve. expect(statusById.get(staleMissingSignatureRequest.id)).toBe("cancelled"); // A request signed with a rotated/old secret is unverifiable and is cancelled. expect(statusById.get(oldSecretRequest.id)).toBe("cancelled"); }); it("tracks new profile tools, reviews mixed allow/block decisions, and clears pending counts", async () => { const company = await createCompany(db); const app = createRouteApp(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: `Review app ${randomUUID()}`, type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: `Review connection ${randomUUID()}`, uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://review.example/mcp" }, transportConfig: { url: "https://review.example/mcp" }, healthStatus: "ok", }) .returning(); const oldSeenAt = new Date("2026-01-01T00:00:00.000Z"); const profileCreatedAt = new Date("2026-01-02T00:00:00.000Z"); const newSeenAt = new Date("2026-01-03T00:00:00.000Z"); const [oldEntry] = await db .insert(toolCatalogEntries) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "read_email", toolName: "read_email", title: "Read email", description: "Read mailbox messages.", riskLevel: "read", isReadOnly: true, status: "active", versionHash: "old-v1", schemaHash: "old-s1", firstSeenAt: oldSeenAt, lastSeenAt: oldSeenAt, }) .returning(); const [sendEntry, deleteEntry] = await db .insert(toolCatalogEntries) .values([ { companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "send_email", toolName: "send_email", title: "Send email", description: "Send outbound messages.", riskLevel: "write" as const, isReadOnly: false, isWrite: true, status: "active" as const, versionHash: "send-v1", schemaHash: "send-s1", firstSeenAt: newSeenAt, lastSeenAt: newSeenAt, }, { companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "delete_email", toolName: "delete_email", title: "Delete email", description: "Delete mailbox messages.", riskLevel: "destructive" as const, isReadOnly: false, isDestructive: true, status: "active" as const, versionHash: "delete-v1", schemaHash: "delete-s1", firstSeenAt: newSeenAt, lastSeenAt: newSeenAt, }, ]) .returning(); const [profile] = await db .insert(toolProfiles) .values({ companyId: company.id, profileKey: `review-${randomUUID()}`, name: "Read-only starter", status: "active", defaultAction: "deny", createdAt: profileCreatedAt, updatedAt: profileCreatedAt, }) .returning(); await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id, selectorType: "catalog_entry", effect: "include", applicationId: application.id, connectionId: connection.id, catalogEntryId: oldEntry.id, }); const listRes = await request(app).get( `/api/companies/${company.id}/tools/profiles`, ); expect(listRes.status).toBe(200); expect(listRes.body.profiles).toContainEqual( expect.objectContaining({ id: profile.id, newToolsPendingCount: 2, }), ); const detailRes = await request(app).get( `/api/tool-profiles/${profile.id}/new-tools`, ); expect(detailRes.status).toBe(200); expect(detailRes.body).toMatchObject({ profileId: profile.id, pendingCount: 2, tools: expect.arrayContaining([ expect.objectContaining({ catalogEntryId: sendEntry.id, toolName: "send_email", applicationName: application.name, connectionName: connection.name, capability: "write", addedAt: newSeenAt.toISOString(), }), expect.objectContaining({ catalogEntryId: deleteEntry.id, capability: "destructive", }), ]), }); const reviewRes = await request(app) .post(`/api/tool-profiles/${profile.id}/new-tools/review`) .send({ decisions: [ { catalogEntryId: sendEntry.id, decision: "allow" }, { catalogEntryId: deleteEntry.id, decision: "keep_blocked" }, ], }); expect(reviewRes.status).toBe(200); expect(reviewRes.body).toMatchObject({ allowedCount: 1, keptBlockedCount: 1, profile: expect.objectContaining({ id: profile.id, newToolsPendingCount: 0, }), entriesCreated: [ expect.objectContaining({ catalogEntryId: sendEntry.id, effect: "include", }), ], reviewedCatalogEntryIds: expect.arrayContaining([ sendEntry.id, deleteEntry.id, ]), }); const profileEntries = await db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, profile.id)); expect( profileEntries.some( (entry) => entry.catalogEntryId === sendEntry.id && entry.effect === "include", ), ).toBe(true); expect( profileEntries.some((entry) => entry.catalogEntryId === deleteEntry.id), ).toBe(false); const [reviewedProfile] = await db .select() .from(toolProfiles) .where(eq(toolProfiles.id, profile.id)); expect(reviewedProfile.newToolsReviewedAt).toBeInstanceOf(Date); const afterReviewRes = await request(app).get( `/api/companies/${company.id}/tools/profiles`, ); expect(afterReviewRes.body.profiles).toContainEqual( expect.objectContaining({ id: profile.id, newToolsPendingCount: 0, }), ); }); it("returns addedAt for auto-allowed effective profile tools without pending review state", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: "Tool User", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "Auto app", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "Auto connection", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://auto.example/mcp" }, transportConfig: { url: "https://auto.example/mcp" }, healthStatus: "ok", }) .returning(); const addedAt = new Date("2026-02-03T00:00:00.000Z"); const [catalogEntry] = await db .insert(toolCatalogEntries) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "auto_allowed", toolName: "auto_allowed", riskLevel: "write", isWrite: true, status: "active", versionHash: "auto-v1", schemaHash: "auto-s1", firstSeenAt: addedAt, lastSeenAt: addedAt, }) .returning(); const [profile] = await db .insert(toolProfiles) .values({ companyId: company.id, profileKey: `auto-${randomUUID()}`, name: "Auto allow", status: "active", defaultAction: "allow", }) .returning(); await db.insert(toolProfileBindings).values({ companyId: company.id, profileId: profile.id, targetType: "company", targetId: company.id, }); const effective = await service.getEffectiveProfilesForAgent( company.id, agent.id, ); expect(effective.allowedTools).toContainEqual( expect.objectContaining({ id: catalogEntry.id, addedAt, firstSeenAt: addedAt, }), ); const profiles = await service.listProfiles(company.id); expect( profiles.find((item) => item.id === profile.id)?.newToolsPendingCount, ).toBe(0); }); it("surfaces and clears profile new-tools attention feed items", async () => { const company = await createCompany(db); const app = createRouteApp(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "Attention review app", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "Attention review connection", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://attention-review.example/mcp" }, transportConfig: { url: "https://attention-review.example/mcp" }, healthStatus: "ok", }) .returning(); const [oldEntry] = await db .insert(toolCatalogEntries) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "read_records", toolName: "read_records", riskLevel: "read", isReadOnly: true, status: "active", versionHash: "read-v1", schemaHash: "read-s1", firstSeenAt: new Date("2026-03-01T00:00:00.000Z"), lastSeenAt: new Date("2026-03-01T00:00:00.000Z"), }) .returning(); const [newEntry] = await db .insert(toolCatalogEntries) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "write_records", toolName: "write_records", riskLevel: "write", isWrite: true, status: "active", versionHash: "write-v1", schemaHash: "write-s1", firstSeenAt: new Date("2026-03-03T00:00:00.000Z"), lastSeenAt: new Date("2026-03-03T00:00:00.000Z"), }) .returning(); const [profile] = await db .insert(toolProfiles) .values({ companyId: company.id, profileKey: `attention-review-${randomUUID()}`, name: "Read-only starter", status: "active", defaultAction: "deny", createdAt: new Date("2026-03-02T00:00:00.000Z"), updatedAt: new Date("2026-03-02T00:00:00.000Z"), }) .returning(); await db.insert(toolProfileEntries).values({ companyId: company.id, profileId: profile.id, selectorType: "catalog_entry", effect: "include", applicationId: application.id, connectionId: connection.id, catalogEntryId: oldEntry.id, }); const attentionRes = await request(app).get( `/api/companies/${company.id}/tools/apps/attention`, ); expect(attentionRes.status).toBe(200); expect(attentionRes.body.totals).toMatchObject({ connections: 1, newToolsPendingReview: 1, newToolsPendingProfiles: 1, }); expect(attentionRes.body.apps).toEqual([ expect.objectContaining({ connection: expect.objectContaining({ id: connection.id }), newToolsPendingReviewCount: 1, newToolsPendingProfiles: [ expect.objectContaining({ profileId: profile.id, profileName: "Read-only starter", pendingCount: 1, }), ], reasons: ["profile_new_tools"], }), ]); const reviewRes = await request(app) .post(`/api/tool-profiles/${profile.id}/new-tools/review`) .send({ decisions: [{ catalogEntryId: newEntry.id, decision: "keep_blocked" }], }); expect(reviewRes.status).toBe(200); const clearedRes = await request(app).get( `/api/companies/${company.id}/tools/apps/attention`, ); expect(clearedRes.body.totals).toMatchObject({ connections: 0, newToolsPendingReview: 0, newToolsPendingProfiles: 0, }); expect(clearedRes.body.apps).toEqual([]); }); it("rolls back app connect drafts when health check fails", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); await expect( service.connectGalleryApp( company.id, { link: "https://broken.example/mcp", name: "Broken app", }, { actorType: "user", actorId: "board" }, ), ).rejects.toMatchObject({ status: 502 }); await expect(db.select().from(toolApplications)).resolves.toHaveLength(0); await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); await expect(db.select().from(toolCatalogEntries)).resolves.toHaveLength(0); }); it("reuses and revives an existing application when connecting with applicationId", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "read_items", description: "Read items.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, ]); const first = await service.connectGalleryApp( company.id, { link: "https://reuse.example.test/actions", name: "Reusable app", }, { actorType: "user", actorId: "board" }, ); const applicationId = first.application.id; // Simulate "Remove app": archive the connection and its application. await db .update(toolConnections) .set({ status: "archived" }) .where(eq(toolConnections.id, first.connectionId)); await db .update(toolApplications) .set({ status: "archived", archivedAt: new Date() }) .where(eq(toolApplications.id, applicationId)); const second = await service.connectGalleryApp( company.id, { link: "https://reuse.example.test/actions", name: "Reusable app", applicationId, }, { actorType: "user", actorId: "board" }, ); expect(second.application.id).toBe(applicationId); // The archived connection is revived in place, not duplicated. expect(second.connectionId).toBe(first.connectionId); await expect(db.select().from(toolApplications)).resolves.toHaveLength(1); await expect(db.select().from(toolConnections)).resolves.toHaveLength(1); const [revived] = await db .select() .from(toolApplications) .where(eq(toolApplications.id, applicationId)); expect(revived.status).toBe("draft"); expect(revived.archivedAt).toBeNull(); await expect( service.connectGalleryApp( company.id, { link: "https://reuse.example.test/actions", applicationId: randomUUID(), }, { actorType: "user", actorId: "board" }, ), ).rejects.toMatchObject({ status: 404 }); }); it("reuses a removed gallery app while applying the omitted organization identity default", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const actor = { actorType: "user" as const, actorId: "local-board" }; const first = await service.connectGalleryApp( company.id, { galleryKey: "notion", name: "Notion", grantKind: "user", }, actor, ); await service.archiveConnection(first.connectionId, company.id, actor); const second = await service.connectGalleryApp( company.id, { galleryKey: "notion", name: "Notion", }, actor, ); expect(second.application.id).toBe(first.application.id); expect(second.connectionId).toBe(first.connectionId); expect(second.application.status).toBe("draft"); expect(second.connection.status).toBe("draft"); expect(second.connection.credentialPolicy).toBe("shared"); const grants = await service.listConnectionGrants( second.connectionId, company.id, ); expect(grants.grants).toEqual([ expect.objectContaining({ kind: "organization", status: "active", isDefault: true, }), ]); await expect(db.select().from(toolApplications)).resolves.toHaveLength(1); await expect(db.select().from(toolConnections)).resolves.toHaveLength(1); }); it("restores grants and credential policy when an identity-changing revival fails", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const actor = { actorType: "user" as const, actorId: "local-board", actorSource: "local_implicit" as const, }; const fetchMock = mockToolsList([ { name: "get_file_contents", annotations: { readOnlyHint: true } }, ]); const first = await service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", grantKind: "organization", name: "GitHub rollback", credentialValues: { "credentials.authorization": "old-organization-token", }, }, actor, ); await service.archiveConnection(first.connectionId, company.id, actor); const beforeConnection = await service.getConnection( first.connectionId, company.id, ); const beforeGrants = await service.listConnectionGrants( first.connectionId, company.id, ); fetchMock.mockRejectedValue(new Error("provider unavailable")); await expect( service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", grantKind: "user", name: "GitHub rollback", credentialValues: { "credentials.authorization": "new-personal-token", }, }, actor, ), ).rejects.toMatchObject({ status: 502 }); await expect( service.getConnection(first.connectionId, company.id), ).resolves.toMatchObject({ status: beforeConnection.status, credentialPolicy: beforeConnection.credentialPolicy, credentialSecretRefs: beforeConnection.credentialSecretRefs, }); const afterGrants = await service.listConnectionGrants( first.connectionId, company.id, ); expect(afterGrants.grants).toEqual(beforeGrants.grants); }); it("preserves a concurrent grant change when an identity-changing revival fails", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const actor = { actorType: "user" as const, actorId: "local-board", actorSource: "local_implicit" as const, }; const fetchMock = mockToolsList([ { name: "get_file_contents", annotations: { readOnlyHint: true } }, ]); const first = await service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", grantKind: "organization", name: "GitHub concurrent rollback", credentialValues: { "credentials.authorization": "old-organization-token", }, }, actor, ); await service.archiveConnection(first.connectionId, company.id, actor); fetchMock.mockImplementation(async () => { const [personalGrant] = await db .select() .from(connectionGrants) .where( and( eq(connectionGrants.connectionId, first.connectionId), eq(connectionGrants.kind, "user"), ), ) .limit(1); expect(personalGrant).toBeTruthy(); const concurrentUpdateAt = new Date(Date.now() + 2_000); await db .update(connectionGrants) .set({ status: "revoked", credentialSecretRefs: [], revokedAt: concurrentUpdateAt, updatedAt: concurrentUpdateAt, }) .where(eq(connectionGrants.id, personalGrant!.id)); throw new Error("provider unavailable"); }); await expect( service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", grantKind: "user", name: "GitHub concurrent rollback", credentialValues: { "credentials.authorization": "new-personal-token", }, }, actor, ), ).rejects.toMatchObject({ status: 502 }); await expect( service.getConnection(first.connectionId, company.id), ).resolves.toMatchObject({ status: "archived", credentialPolicy: "shared", }); const afterGrants = await service.listConnectionGrants( first.connectionId, company.id, ); expect(afterGrants.grants).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "organization", status: "revoked" }), expect.objectContaining({ kind: "user", status: "revoked", credentialSecretRefs: [], }), ]), ); }); it("preserves a concurrent connection update when an identity-changing revival fails", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const actor = { actorType: "user" as const, actorId: "local-board", actorSource: "local_implicit" as const, }; const fetchMock = mockToolsList([ { name: "get_file_contents", annotations: { readOnlyHint: true } }, ]); const first = await service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", grantKind: "organization", name: "GitHub concurrent connection rollback", credentialValues: { "credentials.authorization": "old-organization-token", }, }, actor, ); await service.archiveConnection(first.connectionId, company.id, actor); fetchMock.mockImplementation(async () => { const concurrentUpdateAt = new Date(Date.now() + 2_000); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, first.connectionId)); await db .update(toolConnections) .set({ status: "active", enabled: true, config: { ...connection.config, concurrentOAuthCompletion: true }, transportConfig: { ...connection.transportConfig, concurrentOAuthCompletion: true, }, updatedAt: concurrentUpdateAt, }) .where(eq(toolConnections.id, first.connectionId)); throw new Error("provider unavailable"); }); await expect( service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", grantKind: "user", name: "GitHub concurrent connection rollback", credentialValues: { "credentials.authorization": "new-personal-token", }, }, actor, ), ).rejects.toMatchObject({ status: 502 }); await expect( service.getConnection(first.connectionId, company.id), ).resolves.toMatchObject({ status: "active", enabled: true, credentialPolicy: "per_user", config: expect.objectContaining({ concurrentOAuthCompletion: true }), }); const afterGrants = await service.listConnectionGrants( first.connectionId, company.id, ); const personalGrant = afterGrants.grants.find( (grant) => grant.kind === "user", ); expect(personalGrant).toMatchObject({ status: "active" }); expect(personalGrant?.credentialSecretRefs).toHaveLength(1); const [preservedSecret] = await db .select() .from(companySecrets) .where( eq(companySecrets.id, personalGrant!.credentialSecretRefs[0]!.secretId), ); expect(preservedSecret.deletedAt).toBeNull(); }); it("fails closed when an identity-changing revival cannot roll back", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const actor = { actorType: "user" as const, actorId: "local-board", actorSource: "local_implicit" as const, }; const fetchMock = mockToolsList([ { name: "get_file_contents", annotations: { readOnlyHint: true } }, ]); const first = await service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", grantKind: "organization", name: "GitHub rollback failure", credentialValues: { "credentials.authorization": "old-organization-token", }, }, actor, ); await service.archiveConnection(first.connectionId, company.id, actor); fetchMock.mockRejectedValue(new Error("provider unavailable")); const runTransaction = db.transaction.bind(db); vi.spyOn(db, "transaction") .mockImplementationOnce(runTransaction) .mockRejectedValueOnce(new Error("rollback unavailable")); await expect( service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", grantKind: "user", name: "GitHub rollback failure", credentialValues: { "credentials.authorization": "new-personal-token", }, }, actor, ), ).rejects.toMatchObject({ status: 500, details: { code: "connection_identity_rollback_failed" }, }); await expect( service.getConnection(first.connectionId, company.id), ).resolves.toMatchObject({ status: "draft", enabled: false, healthStatus: "error", lastError: "connection_identity_rollback_failed", }); }); it("automatically gives same-named connections distinct names", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "read_items", description: "Read items.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, ]); const first = await service.connectGalleryApp( company.id, { link: "https://first.example.test/actions", name: "Notion", }, { actorType: "user", actorId: "board" }, ); const second = await service.connectGalleryApp( company.id, { link: "https://second.example.test/actions", name: "Notion", applicationId: first.application.id, }, { actorType: "user", actorId: "board" }, ); expect(second.application.id).toBe(first.application.id); expect(second.connectionId).not.toBe(first.connectionId); const rows = await db .select() .from(toolConnections) .where(eq(toolConnections.applicationId, first.application.id)); expect(rows).toHaveLength(2); expect(rows.map((row) => row.name).sort()).toEqual([ "Notion", "Notion (2)", ]); expect(new Set(rows.map((row) => row.uid))).toHaveProperty("size", 2); }); it("automatically resolves same-name application races", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "read_items", description: "Read items.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, ]); const results = await Promise.all([ service.connectGalleryApp( company.id, { link: "https://parallel-one.example.test/actions", name: "Parallel app", }, { actorType: "user", actorId: "board" }, ), service.connectGalleryApp( company.id, { link: "https://parallel-two.example.test/actions", name: "Parallel app", }, { actorType: "user", actorId: "board" }, ), ]); expect( new Set(results.map((result) => result.application.id)), ).toHaveProperty("size", 2); const applications = await db .select({ name: toolApplications.name }) .from(toolApplications) .where(eq(toolApplications.companyId, company.id)); expect(applications.map((row) => row.name).sort()).toEqual([ "Parallel app", "Parallel app (2)", ]); }); it("does not delete a reused application when the connect rolls back", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "read_items", description: "Read items.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, ]); const first = await service.connectGalleryApp( company.id, { link: "https://rollback.example.test/actions", name: "Rollback app", }, { actorType: "user", actorId: "board" }, ); await db .update(toolConnections) .set({ status: "archived" }) .where(eq(toolConnections.id, first.connectionId)); await db .update(toolApplications) .set({ status: "archived", archivedAt: new Date() }) .where(eq(toolApplications.id, first.application.id)); vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); await expect( service.connectGalleryApp( company.id, { link: "https://rollback.example.test/actions", applicationId: first.application.id, }, { actorType: "user", actorId: "board" }, ), ).rejects.toMatchObject({ status: 502 }); const [stillThere] = await db .select() .from(toolApplications) .where(eq(toolApplications.id, first.application.id)); expect(stillThere).toBeTruthy(); expect(stillThere.status).toBe("archived"); const [connectionBack] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, first.connectionId)); expect(connectionBack.status).toBe("archived"); }); it("connects pasted links with an optional secret-backed app key", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ { name: "read_items", description: "Read items.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, ]); const connect = await service.connectGalleryApp( company.id, { link: "https://links.example.test/actions", name: "Linked app", credentialValues: { "credentials.authorization": "link-secret" }, }, { actorType: "user", actorId: "board" }, ); expect(fetchMock).toHaveBeenCalledWith( "https://links.example.test/actions", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer link-secret", }), }), ); expect(connect.connection).toMatchObject({ status: "draft", enabled: false, config: { url: "https://links.example.test/actions", quarantineNewEntries: false, }, credentialSecretRefs: [ expect.objectContaining({ configPath: "credentials.authorization", label: "App key", }), ], }); expect(JSON.stringify(connect.connection.config)).not.toContain( "link-secret", ); await expect(db.select().from(companySecrets)).resolves.toHaveLength(1); await expect(db.select().from(companySecretBindings)).resolves.toHaveLength( 1, ); }); it("returns a sign-in-required code when a pasted link answers with an OAuth challenge", async () => { const company = await createCompany(db); const app = createRouteApp(db); vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 401, headers: { get: (name: string) => name.toLowerCase() === "www-authenticate" ? 'Bearer realm="app"' : null, }, text: async () => JSON.stringify({ error: "unauthorized" }), json: async () => ({}), } as Response); const res = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ link: "https://signin.example.test/actions", name: "Sign-in app", }); expect(res.status).toBe(502); expect(res.body).toMatchObject({ error: "This app needs you to sign in.", details: expect.objectContaining({ code: "oauth_challenge" }), }); await expect(db.select().from(toolApplications)).resolves.toHaveLength(0); await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); }); it.each([ [ "local_trusted", { deploymentMode: "local_trusted" as const, deploymentExposure: "private" as const, }, ], [ "authenticated/private", { deploymentMode: "authenticated" as const, deploymentExposure: "private" as const, }, ], [ "authenticated/public", { deploymentMode: "authenticated" as const, deploymentExposure: "public" as const, }, ], ])( "rejects OAuth metadata redirects to link-local endpoints in %s", async (_label, deployment) => { const company = await createCompany(db); const app = createRouteApp(db, undefined, undefined, deployment, false); const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url, init) => { const href = String(url); if (href === "https://8.8.8.8/mcp") { return { ok: false, status: 401, headers: { get: (name: string) => name.toLowerCase() === "www-authenticate" ? 'Bearer resource_metadata="https://8.8.8.8/.well-known/oauth-protected-resource"' : null, }, text: async () => "", json: async () => ({}), } as Response; } if (href === "https://8.8.8.8/.well-known/oauth-protected-resource") { expect(init?.redirect).toBe("manual"); return { ok: false, status: 302, headers: { get: (name: string) => name.toLowerCase() === "location" ? "http://169.254.169.254/oauth" : null, }, json: async () => ({}), } as Response; } throw new Error(`unexpected fetch ${href}`); }); const res = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ link: "https://8.8.8.8/mcp", name: "Redirect OAuth MCP" }); expect(res.status).toBe(502); expect(fetchMock).toHaveBeenCalledWith( "https://8.8.8.8/.well-known/oauth-protected-resource", expect.objectContaining({ redirect: "manual" }), ); expect( fetchMock.mock.calls.some(([url]) => String(url).startsWith("http://169.254.169.254"), ), ).toBe(false); }, ); it("discovers OAuth for pasted MCP links and completes sign-in without a gallery entry", async () => { vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_GENERIC_EXAMPLE_TEST_CLIENT_ID", "generic-client-id", ); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_GENERIC_EXAMPLE_TEST_CLIENT_SECRET", "generic-client-secret", ); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); await grantBoardUser(db, company.id, "board-user", [ "tools:manage_connections", ]); const app = createRouteApp(db); const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url, init) => { const href = String(url); if (href === "https://generic.example.test/mcp") { return { ok: false, status: 401, headers: { get: (name: string) => name.toLowerCase() === "www-authenticate" ? 'Bearer resource_metadata="https://generic.example.test/.well-known/oauth-protected-resource"' : null, }, text: async () => "", json: async () => ({}), } as Response; } if ( href === "https://generic.example.test/.well-known/oauth-protected-resource" ) { return { ok: true, json: async () => ({ authorization_endpoint: "https://generic.example.test/oauth/authorize", token_endpoint: "https://generic.example.test/oauth/token", scopes_supported: ["tools.read", "tools.write"], }), } as Response; } if (href === "https://generic.example.test/oauth/token") { const body = init?.body as URLSearchParams; expect(body.get("grant_type")).toBe("authorization_code"); expect(body.get("client_id")).toBe("generic-client-id"); expect(body.get("client_secret")).toBe("generic-client-secret"); return { ok: true, json: async () => ({ access_token: "generic-access-token", refresh_token: "generic-refresh-token", expires_in: 3600, token_type: "Bearer", scope: "tools.read tools.write", }), } as Response; } throw new Error(`unexpected fetch ${href}`); }); const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .send({ link: "https://generic.example.test/mcp", name: "Generic OAuth MCP", }); expect(connectRes.status).toBe(201); expect(connectRes.body.auth).toMatchObject({ kind: "oauth" }); const startUrl = new URL(connectRes.body.auth.startUrl); expect(`${startUrl.origin}${startUrl.pathname}`).toBe( "https://generic.example.test/oauth/authorize", ); expect(startUrl.searchParams.get("client_id")).toBe("generic-client-id"); expect(startUrl.searchParams.get("scope")).toBe("tools.read tools.write"); const state = startUrl.searchParams.get("state"); expect(state).toBeTruthy(); expect(connectRes.body.connection.config.oauth).toMatchObject({ provider: "generic_example_test", tokenUrl: "https://generic.example.test/oauth/token", grantType: "authorization_code", }); fetchMock.mockImplementation(async (url, init) => { const href = String(url); if (href === "https://generic.example.test/oauth/token") { const body = init?.body as URLSearchParams; expect(body.get("code")).toBe("generic-code"); return { ok: true, json: async () => ({ access_token: "generic-access-token", refresh_token: "generic-refresh-token", expires_in: 3600, token_type: "Bearer", }), } as Response; } if (href === "https://generic.example.test/mcp") { expect(init?.headers).toEqual( expect.objectContaining({ Authorization: "Bearer generic-access-token", }), ); return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "read_generic", annotations: { readOnlyHint: true } }, ], }, }); } throw new Error(`unexpected fetch ${href}`); }); const callbackRes = await request(app) .get("/api/tools/oauth/callback") .query({ state, code: "generic-code" }); expect(callbackRes.status).toBe(200); expect(callbackRes.body.catalog).toEqual([ expect.objectContaining({ toolName: "read_generic", riskLevel: "read" }), ]); const [connection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connectRes.body.connectionId)); expect(connection.config).toMatchObject({ oauth: expect.objectContaining({ provider: "generic_example_test", credentialScope: expect.objectContaining({ type: "user" }), }), }); expect(JSON.stringify(connection.config)).not.toContain( "generic-access-token", ); }); it("blocks Smoke Lab OAuth issuer URLs from the normal tool OAuth secret pipeline", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const smokeAuthorizeUrl = `http://127.0.0.1:3100/api/companies/${company.id}/smoke-lab/oauth/authorize`; const smokeTokenUrl = `http://127.0.0.1:3100/api/companies/${company.id}/smoke-lab/oauth/token`; const [application] = await db .insert(toolApplications) .values({ companyId: company.id, applicationKey: `smoke-oauth-masquerade-${randomUUID()}`, name: "Smoke OAuth masquerade", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application!.id, name: "Smoke OAuth masquerade connection", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: false, healthStatus: "unchecked", config: { url: "http://127.0.0.1:3100/mcp", oauth: { provider: "smoke_lab", authorizationUrl: smokeAuthorizeUrl, tokenUrl: smokeTokenUrl, scopes: ["repo", "user:email", "offline_access"], }, }, transportConfig: { url: "http://127.0.0.1:3100/mcp" }, credentialSecretRefs: [], credentialRefs: [], }) .returning(); await expect( service.startOAuth(company.id, connection!.id, { redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }), ).rejects.toMatchObject({ status: 422, message: "Smoke Lab OAuth provider cannot be used for tool app sign-in", }); await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0); await db.insert(toolOauthStates).values({ state: "legacy-smoke-state", companyId: company.id, connectionId: connection!.id, codeVerifier: "legacy-smoke-code-verifier", createdByActorType: "user", createdByActorId: "board", createdBySessionId: null, expiresAt: new Date(Date.now() + 60_000), }); const fetchMock = vi .spyOn(globalThis, "fetch") .mockRejectedValue( new Error("smoke OAuth token endpoint must not be called"), ); await expect( service.completeOAuthCallback({ state: "legacy-smoke-state", code: "smoke-code", redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }), ).rejects.toMatchObject({ status: 422, message: "Smoke Lab OAuth provider cannot be used for tool app sign-in", }); expect(fetchMock).not.toHaveBeenCalled(); const [updatedConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connection!.id)); expect(updatedConnection!.credentialSecretRefs).toEqual([]); await expect(db.select().from(companySecretBindings)).resolves.toHaveLength( 0, ); await expect(db.select().from(companySecrets)).resolves.toHaveLength(0); }); it("starts OAuth only for the marked Smoke Lab HTTP fixture", async () => { const company = await createCompany(db); await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, applicationKey: "paperclip.smoke-lab.http-fixture", name: "Smoke Lab HTTP MCP fixture", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application!.id, name: "Smoke Lab HTTP MCP fixture", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, healthStatus: "ok", config: { smokeLabFixture: "oauth-http", url: "http://smoke-fixture.test/mcp", oauth: { provider: "smoke_lab", smokeLabFixture: true, scopes: ["smoke:openid", "smoke:profile", "smoke:email"], }, }, transportConfig: {}, credentialSecretRefs: [], credentialRefs: [], }) .returning(); const result = await service.startOAuth(company.id, connection!.id, { redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); const authorizationUrl = new URL(result.authorizationUrl); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe( `http://paperclip.test/api/companies/${company.id}/smoke-lab/oauth/authorize`, ); expect(authorizationUrl.searchParams.get("client_id")).toBe( "paperclip-smoke-lab", ); expect(authorizationUrl.searchParams.get("scope")).toBe( "smoke:openid smoke:profile smoke:email", ); await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(1); const state = authorizationUrl.searchParams.get("state"); expect(state).toBeTruthy(); const fetchMock = vi .spyOn(globalThis, "fetch") .mockImplementation(async (url) => { if (String(url).endsWith("/smoke-lab/oauth/token")) { return { ok: true, json: async () => ({ access_token: "smoke-access-token", refresh_token: "smoke-refresh-token", token_type: "Bearer", scope: "smoke:openid smoke:profile smoke:email", }), } as Response; } if (String(url) === "http://smoke-fixture.test/mcp") { return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "todo.list", annotations: { readOnlyHint: true } }, ], }, }); } throw new Error(`unexpected fetch ${String(url)}`); }); await service.completeOAuthCallback({ state: state!, code: "smoke-code", redirectUri: "http://paperclip.test/api/tools/oauth/callback", actor: { actorType: "user", actorId: "board" }, }); expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain( `http://paperclip.test/api/companies/${company.id}/smoke-lab/oauth/token`, ); expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain( "http://smoke-fixture.test/mcp", ); const [updatedConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connection!.id)); expect(updatedConnection).toMatchObject({ enabled: true }); expect(updatedConnection!.config).toMatchObject({ oauth: expect.objectContaining({ connectedAt: expect.any(String) }), }); }); it("connects gallery apps and finishes access profiles, bindings, and ask-first policies", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ { name: "list_zaps", description: "List Zapier actions.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, { name: "update_zap", description: "Update a Zapier action.", inputSchema: { type: "object", properties: { id: { type: "string" } } }, annotations: { readOnlyHint: false }, }, ]); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: `App Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const connect = await withGalleryServerUrl( "github", PUBLIC_MCP_FIXTURE_URL, () => service.connectGalleryApp( company.id, { galleryKey: "github", connectionMethodKey: "mcp-key", name: "GitHub workspace", credentialValues: { "credentials.authorization": "zap-secret" }, }, { actorType: "user", actorId: "board" }, ), "mcp-key", ); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledWith( PUBLIC_MCP_FIXTURE_URL, expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer zap-secret", }), }), ); expect(connect.connection).toMatchObject({ status: "draft", enabled: false, config: expect.objectContaining({ sourceTemplateKey: "github", quarantineNewEntries: false, }), credentialSecretRefs: [ expect.objectContaining({ configPath: "credentials.authorization", label: "GitHub token", }), ], }); expect(connect.actions.readOnly).toEqual([ expect.objectContaining({ toolName: "list_zaps", riskLevel: "read" }), ]); expect(connect.actions.canMakeChanges).toEqual([ expect.objectContaining({ toolName: "update_zap", riskLevel: "write" }), ]); const listEntry = connect.catalog.find( (entry) => entry.toolName === "list_zaps", )!; const updateEntry = connect.catalog.find( (entry) => entry.toolName === "update_zap", )!; expect(connect.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ id: listEntry.id, status: "active", quarantineReason: null, }), expect.objectContaining({ id: updateEntry.id, status: "active", quarantineReason: null, }), ]), ); const finish = await service.finishGalleryAppConnection( company.id, connect.connectionId, { enabledCatalogEntryIds: [listEntry.id, updateEntry.id], askFirstCatalogEntryIds: [updateEntry.id], access: { agentIds: [agent.id] }, }, { actorType: "user", actorId: "board" }, ); expect(finish.connection).toMatchObject({ id: connect.connectionId, status: "active", enabled: true, }); expect(finish.profile).toMatchObject({ profileKey: `app:${connect.connectionId}`, defaultAction: "deny", status: "active", }); expect(finish.profileEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ selectorType: "catalog_entry", catalogEntryId: listEntry.id, effect: "include", }), expect.objectContaining({ selectorType: "catalog_entry", catalogEntryId: updateEntry.id, effect: "include", }), ]), ); expect(finish.profileBindings).toEqual([ expect.objectContaining({ targetType: "agent", targetId: agent.id }), ]); expect(finish.policies).toEqual([ expect.objectContaining({ policyType: "require_approval", enabled: true, selectors: { catalogEntryId: updateEntry.id }, }), ]); const repeatFinish = await service.finishGalleryAppConnection( company.id, connect.connectionId, { enabledCatalogEntryIds: [listEntry.id, updateEntry.id], askFirstCatalogEntryIds: [updateEntry.id], access: { agentIds: [agent.id, agent.id] }, }, { actorType: "user", actorId: "board" }, ); expect(repeatFinish.profile.id).toBe(finish.profile.id); expect(repeatFinish.profileEntries).toHaveLength(2); expect(repeatFinish.profileBindings).toEqual([ expect.objectContaining({ targetType: "agent", targetId: agent.id }), ]); expect(repeatFinish.policies).toEqual([ expect.objectContaining({ policyType: "require_approval", enabled: true, selectors: { catalogEntryId: updateEntry.id }, }), ]); await expect( db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, finish.profile.id)), ).resolves.toHaveLength(1); await expect( db .select() .from(toolPolicies) .where(eq(toolPolicies.companyId, company.id)), ).resolves.toHaveLength(1); const finishedCatalog = await db .select() .from(toolCatalogEntries) .where(eq(toolCatalogEntries.connectionId, connect.connectionId)); expect(finishedCatalog).toEqual( expect.arrayContaining([ expect.objectContaining({ id: listEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null, }), expect.objectContaining({ id: updateEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null, }), ]), ); await db .update(toolConnections) .set({ config: { ...connect.connection.config, quarantineNewEntries: true }, }) .where(eq(toolConnections.id, connect.connectionId)); fetchMock.mockResolvedValueOnce( mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "list_zaps", description: "List Zapier actions.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, { name: "update_zap", description: "Update a Zapier action with new args.", inputSchema: { type: "object", properties: { id: { type: "string" }, label: { type: "string" }, }, }, annotations: { readOnlyHint: false }, }, { name: "create_zap", description: "Create a Zapier action.", inputSchema: { type: "object", properties: { label: { type: "string" } }, }, annotations: { readOnlyHint: false }, }, ], }, }), ); const rereview = await service.refreshCatalog(connect.connectionId, { actorType: "user", actorId: "board", }); expect(rereview.quarantinedCount).toBe(2); expect(rereview.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "list_zaps", status: "active" }), expect.objectContaining({ toolName: "update_zap", status: "quarantined", quarantineReason: "pending_review", }), expect.objectContaining({ toolName: "create_zap", status: "quarantined", quarantineReason: "pending_review", }), ]), ); const [policy] = await db .select() .from(toolPolicies) .where(eq(toolPolicies.companyId, company.id)); expect(policy).toMatchObject({ policyType: "require_approval", selectors: { catalogEntryId: updateEntry.id }, config: expect.objectContaining({ source: "app_gallery_finish", connectionId: connect.connectionId, catalogEntryId: updateEntry.id, }), }); const createEntry = rereview.catalog.find( (entry) => entry.toolName === "create_zap", )!; await service.finishGalleryAppConnection( company.id, connect.connectionId, { enabledCatalogEntryIds: [listEntry.id, updateEntry.id], askFirstCatalogEntryIds: [updateEntry.id], access: { agentIds: [agent.id] }, }, { actorType: "user", actorId: "board" }, ); const stillQuarantined = await db .select() .from(toolCatalogEntries) .where(eq(toolCatalogEntries.connectionId, connect.connectionId)); expect(stillQuarantined).toEqual( expect.arrayContaining([ expect.objectContaining({ id: updateEntry.id, status: "quarantined" }), expect.objectContaining({ id: createEntry.id, status: "quarantined" }), ]), ); await expect( service.finishGalleryAppConnection( company.id, connect.connectionId, { enabledCatalogEntryIds: [listEntry.id, createEntry.id], askFirstCatalogEntryIds: [createEntry.id], reviewedCatalogEntryIds: [createEntry.id], access: { agentIds: [agent.id] }, }, { actorType: "user", actorId: "board" }, ), ).rejects.toMatchObject({ status: 400, message: "Action review decisions must cover every currently quarantined action exactly once", }); const reviewed = await service.finishGalleryAppConnection( company.id, connect.connectionId, { enabledCatalogEntryIds: [listEntry.id, createEntry.id], askFirstCatalogEntryIds: [createEntry.id], reviewedCatalogEntryIds: [updateEntry.id, createEntry.id], access: { agentIds: [agent.id] }, }, { actorType: "user", actorId: "board" }, ); expect(reviewed.profileEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ catalogEntryId: listEntry.id }), expect.objectContaining({ catalogEntryId: createEntry.id }), ]), ); expect(reviewed.profileEntries).not.toEqual( expect.arrayContaining([ expect.objectContaining({ catalogEntryId: updateEntry.id }), ]), ); const reviewedCatalog = await db .select() .from(toolCatalogEntries) .where(eq(toolCatalogEntries.connectionId, connect.connectionId)); expect(reviewedCatalog).toEqual( expect.arrayContaining([ expect.objectContaining({ id: updateEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null, }), expect.objectContaining({ id: createEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null, }), ]), ); const attentionAfterReview = await service.listAppsNeedingAttention( company.id, ); expect(attentionAfterReview.apps).toEqual([]); }); it("enables newly discovered tools after setup while preserving tools explicitly turned off", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ { name: "list_zaps", annotations: { readOnlyHint: true } }, { name: "update_zap", annotations: { readOnlyHint: false } }, ]); const connect = await withGalleryServerUrl( "zapier", PUBLIC_MCP_FIXTURE_URL, () => service.connectGalleryApp( company.id, { galleryKey: "zapier", credentialValues: { "credentials.authorization": "zap-secret" }, }, { actorType: "user", actorId: "board" }, ), ); const listEntry = connect.catalog.find( (entry) => entry.toolName === "list_zaps", )!; const updateEntry = connect.catalog.find( (entry) => entry.toolName === "update_zap", )!; await service.finishGalleryAppConnection( company.id, connect.connectionId, { enabledCatalogEntryIds: [listEntry.id], askFirstCatalogEntryIds: [], access: "all_agents", }, { actorType: "user", actorId: "board" }, ); const [defaultProfile] = await db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${connect.connectionId}`)); expect(defaultProfile).toBeTruthy(); fetchMock.mockResolvedValueOnce( mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "list_zaps", annotations: { readOnlyHint: true } }, { name: "update_zap", annotations: { readOnlyHint: false } }, { name: "create_zap", annotations: { readOnlyHint: false } }, ], }, }), ); const refresh = await service.refreshCatalog(connect.connectionId, { actorType: "user", actorId: "board", }); expect(refresh.quarantinedCount).toBe(0); expect(refresh.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ toolName: "list_zaps", status: "active" }), expect.objectContaining({ toolName: "update_zap", status: "active" }), expect.objectContaining({ toolName: "create_zap", status: "active" }), ]), ); const createEntry = refresh.catalog.find( (entry) => entry.toolName === "create_zap", )!; const profileEntries = await db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, defaultProfile!.id)); expect(profileEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ catalogEntryId: listEntry.id }), expect.objectContaining({ catalogEntryId: createEntry.id }), ]), ); expect(profileEntries).not.toEqual( expect.arrayContaining([ expect.objectContaining({ catalogEntryId: updateEntry.id }), ]), ); }); it("restores every action as Allowed when a removed app connection is connected again", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const service = createTestToolAccessService(db); mockToolsList([ { name: "list_zaps", annotations: { readOnlyHint: true } }, { name: "update_zap", annotations: { readOnlyHint: false } }, ]); const actor = { actorType: "user" as const, actorId: "board" }; const first = await withGalleryServerUrl( "zapier", PUBLIC_MCP_FIXTURE_URL, () => service.connectGalleryApp( company.id, { galleryKey: "zapier", name: "Zapier reconnect defaults", credentialValues: { "credentials.authorization": "first-secret" }, }, actor, ), ); const readEntry = first.catalog.find( (entry) => entry.toolName === "list_zaps", )!; const writeEntry = first.catalog.find( (entry) => entry.toolName === "update_zap", )!; const legacy = await service.finishGalleryAppConnection( company.id, first.connectionId, { enabledCatalogEntryIds: [writeEntry.id], askFirstCatalogEntryIds: [writeEntry.id], access: "all_agents", }, actor, ); // A gateway reference forces removal to retain the now-archived profile // row, matching the production state that originally exposed this bug. await db.insert(toolMcpGateways).values({ companyId: company.id, name: `Retained gateway ${randomUUID()}`, slug: `retained-${randomUUID()}`, profileId: legacy.profile.id, status: "active", }); await service.archiveConnection(first.connectionId, company.id, actor); await expect( db .select() .from(toolProfiles) .where(eq(toolProfiles.id, legacy.profile.id)), ).resolves.toEqual([expect.objectContaining({ status: "archived" })]); const connectedAgain = await withGalleryServerUrl( "zapier", PUBLIC_MCP_FIXTURE_URL, () => service.connectGalleryApp( company.id, { galleryKey: "zapier", name: "Zapier reconnect defaults", credentialValues: { "credentials.authorization": "second-secret" }, }, actor, ), ); expect(connectedAgain.connectionId).toBe(first.connectionId); const [restoredProfile] = await db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${first.connectionId}`)); expect(restoredProfile).toMatchObject({ id: legacy.profile.id, status: "active", }); await expect( db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, restoredProfile!.id)), ).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ catalogEntryId: readEntry.id, effect: "include", }), expect.objectContaining({ catalogEntryId: writeEntry.id, effect: "include", }), ]), ); await expect( db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, restoredProfile!.id)), ).resolves.toEqual([ expect.objectContaining({ targetType: "company", targetId: company.id }), ]); await expect( db .select() .from(toolPolicies) .where( and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), ), ), ).resolves.toEqual([]); // OAuth activates the connection before the Test tab asks the policy // engine for these decisions. This fixture uses a key-based gallery app to // keep the reconnect setup deterministic, so mirror that final lifecycle // transition here. await db .update(toolConnections) .set({ status: "active", enabled: true }) .where(eq(toolConnections.id, connectedAgain.connectionId)); const policy = toolAccessPolicyService(db); for (const entry of [readEntry, writeEntry]) { await expect( policy.decide({ companyId: company.id, actor: { actorType: "agent", actorId: agent.id, agentId: agent.id }, request: { connectionId: first.connectionId, catalogEntryId: entry.id, toolName: entry.toolName, arguments: {}, }, }), ).resolves.toMatchObject({ decision: "allow", reasonCode: "allow_profile", }); } }); it("resolves Notion reads as allowed, mutations as ask-first, and denies cross-company use", async () => { const company = await createCompany(db); const otherCompany = await createCompany(db); const agent = await createAgent(db, company.id); const service = createTestToolAccessService(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, applicationKey: `app-gallery:notion:${randomUUID()}`, name: "Notion workspace", type: "mcp_http", status: "draft", metadata: { sourceTemplateKey: "notion", galleryKey: "notion" }, }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "Notion workspace", uid: `notion/${randomUUID()}`, transport: "mcp_remote", authKind: "oauth", status: "draft", enabled: false, config: { url: "https://mcp.notion.com/mcp", sourceTemplateKey: "notion", quarantineNewEntries: true, }, transportConfig: { url: "https://mcp.notion.com/mcp" }, healthStatus: "ok", }) .returning(); mockToolsList([ { name: "notion-fetch", annotations: { readOnlyHint: true } }, // These two mutations do not contain a generic create/update verb. { name: "notion-move-pages" }, { name: "notion-duplicate-page", annotations: { readOnlyHint: true } }, ]); const refresh = await service.refreshCatalog(connection.id); const fetchEntry = refresh.catalog.find( (entry) => entry.toolName === "notion-fetch", )!; const moveEntry = refresh.catalog.find( (entry) => entry.toolName === "notion-move-pages", )!; const duplicateEntry = refresh.catalog.find( (entry) => entry.toolName === "notion-duplicate-page", )!; expect(refresh.catalog).toEqual( expect.arrayContaining([ expect.objectContaining({ id: fetchEntry.id, riskLevel: "read", isReadOnly: true, }), expect.objectContaining({ id: moveEntry.id, riskLevel: "write", isWrite: true, }), expect.objectContaining({ id: duplicateEntry.id, riskLevel: "write", isWrite: true, }), ]), ); // A narrower profile must not make an app shared with "All agents" // disappear. App action selection is an additive capability assignment; // ordinary profile precedence still governs non-app defaults. const existingAgentProfile = await service.createProfile(company.id, { profileKey: `existing-agent-profile-${randomUUID()}`, name: "Existing agent defaults", defaultAction: "deny", }); await service.bindProfile( existingAgentProfile.id, { targetType: "agent", targetId: agent.id }, { actorType: "user", actorId: "board" }, ); await expect( service.finishGalleryAppConnection(otherCompany.id, connection.id, { enabledCatalogEntryIds: [fetchEntry.id], askFirstCatalogEntryIds: [], access: "all_agents", }), ).rejects.toMatchObject({ status: 404 }); await service.finishGalleryAppConnection(company.id, connection.id, { enabledCatalogEntryIds: [fetchEntry.id, moveEntry.id, duplicateEntry.id], askFirstCatalogEntryIds: [moveEntry.id, duplicateEntry.id], access: "all_agents", }); const policyService = toolAccessPolicyService(db); const decide = (catalogEntryId: string, toolName: string) => policyService.decide({ companyId: company.id, actor: { actorType: "agent", actorId: agent.id }, request: { connectionId: connection.id, catalogEntryId, toolName, arguments: {}, }, }); await expect( decide(fetchEntry.id, fetchEntry.toolName), ).resolves.toMatchObject({ decision: "allow", reasonCode: "allow_profile", }); await expect( decide(moveEntry.id, moveEntry.toolName), ).resolves.toMatchObject({ decision: "require_approval", reasonCode: "requires_approval_policy", }); await expect( decide(duplicateEntry.id, duplicateEntry.toolName), ).resolves.toMatchObject({ decision: "require_approval", reasonCode: "requires_approval_policy", }); await expect( policyService.decide({ companyId: otherCompany.id, actor: { actorType: "user", actorId: "other-board" }, request: { connectionId: connection.id, catalogEntryId: fetchEntry.id, toolName: fetchEntry.toolName, arguments: {}, }, }), ).resolves.toMatchObject({ decision: "deny", reasonCode: "deny_missing_tool", }); }); it("rolls back gallery app finish when a later write fails after clearing profile state", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ { name: "list_zaps", description: "List Zapier actions.", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, { name: "update_zap", description: "Update a Zapier action.", inputSchema: { type: "object", properties: { id: { type: "string" } } }, annotations: { readOnlyHint: false }, }, ]); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: `Rollback Agent ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const connect = await withGalleryServerUrl( "github", PUBLIC_MCP_FIXTURE_URL, () => service.connectGalleryApp( company.id, { galleryKey: "github", name: "GitHub rollback", credentialValues: { "credentials.authorization": "github-secret" }, }, { actorType: "user", actorId: "board" }, ), ); const listEntry = connect.catalog.find( (entry) => entry.toolName === "list_zaps", )!; const updateEntry = connect.catalog.find( (entry) => entry.toolName === "update_zap", )!; const firstFinish = await service.finishGalleryAppConnection( company.id, connect.connectionId, { enabledCatalogEntryIds: [listEntry.id, updateEntry.id], askFirstCatalogEntryIds: [updateEntry.id], access: { agentIds: [agent.id] }, }, { actorType: "user", actorId: "board" }, ); const entriesBefore = await db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, firstFinish.profile.id)); const bindingsBefore = await db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, firstFinish.profile.id)); const policiesBefore = await db .select() .from(toolPolicies) .where( and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), ), ); await db.insert(toolProfiles).values({ companyId: company.id, profileKey: `conflict-${randomUUID()}`, name: "Conflicting app profile", status: "active", defaultAction: "deny", }); await db .update(toolConnections) .set({ name: "Conflicting app profile", updatedAt: new Date() }) .where(eq(toolConnections.id, connect.connectionId)); await expect( service.finishGalleryAppConnection( company.id, connect.connectionId, { enabledCatalogEntryIds: [listEntry.id, updateEntry.id], askFirstCatalogEntryIds: [updateEntry.id], access: { agentIds: [agent.id] }, }, { actorType: "user", actorId: "board" }, ), ).rejects.toThrow(); const entriesAfter = await db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, firstFinish.profile.id)); const bindingsAfter = await db .select() .from(toolProfileBindings) .where(eq(toolProfileBindings.profileId, firstFinish.profile.id)); const policiesAfter = await db .select() .from(toolPolicies) .where( and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), ), ); expect(entriesAfter.map((entry) => entry.catalogEntryId).sort()).toEqual( entriesBefore.map((entry) => entry.catalogEntryId).sort(), ); expect( bindingsAfter .map((binding) => `${binding.targetType}:${binding.targetId}`) .sort(), ).toEqual( bindingsBefore .map((binding) => `${binding.targetType}:${binding.targetId}`) .sort(), ); expect(policiesAfter.map((policy) => policy.id).sort()).toEqual( policiesBefore.map((policy) => policy.id).sort(), ); }); it("reconnects a gallery app by rotating the existing credential in place (PAP-10859)", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const fetchMock = mockToolsList([ { name: "list_zaps", description: "List", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, { name: "update_zap", description: "Update", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: false }, }, ]); const connect = await withGalleryServerUrl( "github", PUBLIC_MCP_FIXTURE_URL, () => service.connectGalleryApp( company.id, { galleryKey: "github", name: "GitHub reconnect", credentialValues: { "credentials.authorization": "old-secret" }, }, { actorType: "user", actorId: "board" }, ), ); const before = await service.getConnection( connect.connectionId, company.id, ); const beforeRef = before.credentialSecretRefs.find( (r) => r.configPath === "credentials.authorization", )!; expect(beforeRef).toBeDefined(); const listEntry = connect.catalog.find( (entry) => entry.toolName === "list_zaps", )!; const updateEntry = connect.catalog.find( (entry) => entry.toolName === "update_zap", )!; const finished = await service.finishGalleryAppConnection( company.id, connect.connectionId, { enabledCatalogEntryIds: [listEntry.id, updateEntry.id], askFirstCatalogEntryIds: [updateEntry.id], access: "all_agents", }, { actorType: "user", actorId: "board" }, ); await db .delete(toolProfileEntries) .where(eq(toolProfileEntries.profileId, finished.profile.id)); await db .update(toolCatalogEntries) .set({ status: "quarantined", quarantineReason: "pending_review", quarantinedAt: new Date(), }) .where(eq(toolCatalogEntries.connectionId, connect.connectionId)); await db .update(toolConnections) .set({ config: { ...before.config, quarantineNewEntries: true }, transportConfig: { ...before.transportConfig, quarantineNewEntries: true, }, }) .where(eq(toolConnections.id, connect.connectionId)); fetchMock.mockResolvedValue( mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ { name: "list_zaps", description: "List", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, { name: "update_zap", description: "Update", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: false }, }, { name: "delete_zap", description: "Delete", inputSchema: { type: "object", properties: {} }, annotations: { destructiveHint: true }, }, ], }, }), ); await expect( service.reconnectGalleryApp( connect.connectionId, company.id, { credentialValues: {} }, { actorType: "user", actorId: "board" }, ), ).rejects.toMatchObject({ message: expect.stringContaining("Paste a new key"), }); const result = await service.reconnectGalleryApp( connect.connectionId, company.id, { credentialValues: { "credentials.authorization": "new-secret" } }, { actorType: "user", actorId: "board" }, ); expect(result.connection.id).toBe(connect.connectionId); const after = await service.getConnection(connect.connectionId, company.id); const afterRef = after.credentialSecretRefs.find( (r) => r.configPath === "credentials.authorization", )!; // Rotated in place: same secret, no duplicate ref created. expect(after.credentialSecretRefs).toHaveLength( before.credentialSecretRefs.length, ); expect(afterRef.secretId).toBe(beforeRef.secretId); expect(after.config).toMatchObject({ quarantineNewEntries: false }); expect(after.transportConfig).toMatchObject({ quarantineNewEntries: false, }); const catalogAfterReconnect = await db .select() .from(toolCatalogEntries) .where(eq(toolCatalogEntries.connectionId, connect.connectionId)); expect(catalogAfterReconnect).toEqual( expect.arrayContaining([ expect.objectContaining({ id: listEntry.id, status: "active", quarantineReason: null, }), expect.objectContaining({ id: updateEntry.id, status: "active", quarantineReason: null, }), expect.objectContaining({ toolName: "delete_zap", status: "active", riskLevel: "destructive", }), ]), ); const deleteEntry = catalogAfterReconnect.find( (entry) => entry.toolName === "delete_zap", )!; const profileEntriesAfterReconnect = await db .select() .from(toolProfileEntries) .where(eq(toolProfileEntries.profileId, finished.profile.id)); expect(profileEntriesAfterReconnect).toEqual( expect.arrayContaining([ expect.objectContaining({ catalogEntryId: listEntry.id, effect: "include", }), expect.objectContaining({ catalogEntryId: updateEntry.id, effect: "include", }), expect.objectContaining({ catalogEntryId: deleteEntry.id, effect: "include", }), ]), ); const policiesAfterReconnect = await db .select() .from(toolPolicies) .where( and( eq(toolPolicies.companyId, company.id), eq(toolPolicies.enabled, true), ), ); // Reconnect preserves explicit policy choices, but newly discovered // actions start Allowed just like actions from a fresh connection. expect(policiesAfterReconnect).toHaveLength(1); expect(policiesAfterReconnect).toEqual([ expect.objectContaining({ policyType: "require_approval", selectors: { catalogEntryId: updateEntry.id }, }), ]); }); it("reconnects a personal key on the existing user grant without creating an organization credential", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const userId = `personal-key-${randomUUID()}`; mockToolsList([ { name: "list_zaps", description: "List", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true }, }, ]); const connected = await withGalleryServerUrl( "github", PUBLIC_MCP_FIXTURE_URL, () => service.connectGalleryApp( company.id, { galleryKey: "github", name: "Personal GitHub reconnect", grantKind: "user", credentialValues: { "credentials.authorization": "old-personal-secret", }, }, { actorType: "user", actorId: userId }, ), ); const before = await service.getConnection( connected.connectionId, company.id, ); const beforeGrants = await service.listConnectionGrants( connected.connectionId, company.id, ); const beforePersonalGrant = beforeGrants.grants.find( (grant) => grant.kind === "user", )!; expect(before).toMatchObject({ credentialPolicy: "per_user", credentialSecretRefs: [], }); expect(beforePersonalGrant).toMatchObject({ subjectUserId: userId, status: "active", }); expect( beforeGrants.grants.some((grant) => grant.kind === "organization"), ).toBe(false); const beforeSecretId = beforePersonalGrant.credentialSecretRefs[0]!.secretId; await withGalleryServerUrl("github", PUBLIC_MCP_FIXTURE_URL, () => service.reconnectGalleryApp( connected.connectionId, company.id, { credentialValues: { "credentials.authorization": "new-personal-secret", }, }, { actorType: "user", actorId: userId }, ), ); const after = await service.getConnection( connected.connectionId, company.id, ); const afterGrants = await service.listConnectionGrants( connected.connectionId, company.id, ); const afterPersonalGrant = afterGrants.grants.find( (grant) => grant.kind === "user", )!; expect(after).toMatchObject({ credentialPolicy: "per_user", credentialSecretRefs: [], }); expect(afterPersonalGrant).toMatchObject({ subjectUserId: userId, status: "active", }); expect(afterPersonalGrant.credentialSecretRefs[0]!.secretId).toBe( beforeSecretId, ); expect( afterGrants.grants.some((grant) => grant.kind === "organization"), ).toBe(false); await service.archiveConnection(connected.connectionId, company.id, { actorType: "user", actorId: userId, }); const revived = await withGalleryServerUrl( "github", PUBLIC_MCP_FIXTURE_URL, () => service.connectGalleryApp( company.id, { applicationId: connected.application.id, galleryKey: "github", name: "Personal GitHub reconnect", // No grantKind is sent on reconnect: the retained connection owns that // decision and must reactivate this same grant rather than insert a new // one or fall back to an organization credential. credentialValues: { "credentials.authorization": "revived-personal-secret", }, }, { actorType: "user", actorId: userId }, ), ); expect(revived.connectionId).toBe(connected.connectionId); expect(revived.connection).toMatchObject({ credentialPolicy: "per_user", credentialSecretRefs: [], }); const revivedGrants = await service.listConnectionGrants( connected.connectionId, company.id, ); expect( revivedGrants.grants.filter((grant) => grant.kind === "user"), ).toEqual([ expect.objectContaining({ id: beforePersonalGrant.id, subjectUserId: userId, status: "active", }), ]); expect( revivedGrants.grants.some((grant) => grant.kind === "organization"), ).toBe(false); }); it("stops and restarts local stdio runtime slots through the board service", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db, { now: () => new Date("2026-06-06T01:00:00.000Z"), }); const connection = await service.createConnection(company.id, { name: "Restartable local fixture", transport: "local_stdio", config: { templateId: "paperclip.echo-calculator-time" }, enabled: true, status: "active", }); const health = await service.checkHealth(connection.id); expect(health.runtimeSlot).toMatchObject({ connectionId: connection.id, status: "stopped", runtimeKind: "local_stdio", }); const restarted = await service.restartRuntimeSlot( company.id, health.runtimeSlot!.id, { actorType: "user", actorId: "board-user", }, ); expect(restarted).toMatchObject({ id: health.runtimeSlot!.id, status: "running", runtimeKind: "local_stdio", healthStatus: "ok", }); expect(restarted.providerRef).toMatch(/^local-stdio:/); const stopped = await service.stopRuntimeSlot( company.id, health.runtimeSlot!.id, { actorType: "user", actorId: "board-user", }, ); expect(stopped).toMatchObject({ id: health.runtimeSlot!.id, status: "stopped", healthMessage: "Runtime slot stopped.", }); const activities = await db.select().from(activityLog); expect(activities).toEqual( expect.arrayContaining([ expect.objectContaining({ actorType: "user", actorId: "board-user", action: "tool_runtime_slot.operator_restarted", entityId: health.runtimeSlot!.id, }), expect.objectContaining({ actorType: "user", actorId: "board-user", action: "tool_runtime_slot.operator_stopped", entityId: health.runtimeSlot!.id, }), ]), ); }); it("exposes board runtime slot stop and restart endpoints", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const app = createRouteApp(db); const connection = await service.createConnection(company.id, { name: "Route local fixture", transport: "local_stdio", config: { templateId: "paperclip.echo-calculator-time" }, enabled: true, status: "active", }); const health = await service.checkHealth(connection.id); const slotId = health.runtimeSlot!.id; const restart = await request(app) .post( `/api/companies/${company.id}/tools/runtime-slots/${slotId}/restart`, ) .send({}); expect(restart.status).toBe(200); expect(restart.body).toMatchObject({ id: slotId, companyId: company.id, runtimeKind: "local_stdio", status: "running", }); const stop = await request(app) .post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/stop`) .send({}); expect(stop.status).toBe(200); expect(stop.body).toMatchObject({ id: slotId, companyId: company.id, runtimeKind: "local_stdio", status: "stopped", }); }); it("requires tools:manage_runtime for company-scoped runtime slot routes", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const userId = `runtime-operator-${randomUUID()}`; await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: userId, status: "active", membershipRole: "operator", }); const actor = boardSessionActor(company.id, "operator", userId); const app = createRouteApp(db, actor); const connection = await service.createConnection(company.id, { name: "Permissioned local fixture", transport: "local_stdio", config: { templateId: "paperclip.echo-calculator-time" }, enabled: true, status: "active", }); const health = await service.checkHealth(connection.id); const slotId = health.runtimeSlot!.id; await request(app) .get(`/api/companies/${company.id}/tools/runtime-slots`) .expect(403); await request(app) .post( `/api/companies/${company.id}/tools/runtime-slots/${slotId}/restart`, ) .send({}) .expect(403); await request(app) .post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/stop`) .send({}) .expect(403); await db.insert(principalPermissionGrants).values({ companyId: company.id, principalType: "user", principalId: userId, permissionKey: "tools:manage_runtime", scope: null, grantedByUserId: "owner", }); const list = await request(app) .get(`/api/companies/${company.id}/tools/runtime-slots`) .expect(200); expect(list.body.runtimeSlots).toEqual( expect.arrayContaining([ expect.objectContaining({ id: slotId, runtimeKind: "local_stdio" }), ]), ); const restart = await request(app) .post( `/api/companies/${company.id}/tools/runtime-slots/${slotId}/restart`, ) .send({}) .expect(200); expect(restart.body).toMatchObject({ id: slotId, companyId: company.id, runtimeKind: "local_stdio", status: "running", }); const stop = await request(app) .post(`/api/companies/${company.id}/tools/runtime-slots/${slotId}/stop`) .send({}) .expect(200); expect(stop.body).toMatchObject({ id: slotId, companyId: company.id, runtimeKind: "local_stdio", status: "stopped", }); }); it("updates tool applications through the board route and records activity", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const app = createRouteApp(db); const application = await service.createApplication(company.id, { name: "Editable app", description: "Before", type: "mcp_http", }); const res = await request(app) .patch(`/api/tool-applications/${application.id}`) .send({ name: "Edited app", description: "After" }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ id: application.id, companyId: company.id, name: "Edited app", description: "After", type: "mcp_http", }); const activities = await db .select() .from(activityLog) .where(eq(activityLog.entityId, application.id)); expect(activities).toEqual([ expect.objectContaining({ action: "tool_application.updated", companyId: company.id, details: expect.objectContaining({ name: "Edited app" }), }), ]); }); it("returns 409 instead of 500 when an application update collides with a duplicate name", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const app = createRouteApp(db); await service.createApplication(company.id, { name: "Existing app", type: "mcp_http", }); const application = await service.createApplication(company.id, { name: "Editable app", type: "mcp_http", }); const res = await request(app) .patch(`/api/tool-applications/${application.id}`) .send({ name: "Existing app" }); expect(res.status).toBe(409); expect(res.body).toMatchObject({ error: "A tool access record with that name already exists", }); }); it("returns 404 for cross-company application updates and missing applications", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); const application = await createTestToolAccessService(db).createApplication( otherCompany.id, { name: "Other company app", type: "mcp_http", }, ); const app = createRouteApp(db, { type: "board", userId: "member-user", userName: "Member User", userEmail: null, companyIds: [allowedCompany.id], memberships: [ { companyId: allowedCompany.id, membershipRole: "owner", status: "active", }, ], isInstanceAdmin: false, source: "session", }); const forbiddenRes = await request(app) .patch(`/api/tool-applications/${application.id}`) .send({ name: "Forbidden edit" }); const missingRes = await request(createRouteApp(db)) .patch(`/api/tool-applications/${randomUUID()}`) .send({ name: "Missing edit" }); expect(forbiddenRes.status).toBe(404); expect(missingRes.status).toBe(404); }); it("keeps direct application and connection mutation routes viewer-safe", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const application = await service.createApplication(company.id, { name: "Viewer guarded app", type: "mcp_http", }); const connection = await service.createConnection(company.id, { applicationId: application.id, name: "Viewer guarded connection", transport: "mcp_remote", config: { url: "https://viewer-guard.example/mcp" }, status: "active", enabled: true, }); const viewerApp = createRouteApp( db, boardSessionActor(company.id, "viewer", "viewer-user"), ); const responses = [ await request(viewerApp) .post(`/api/companies/${company.id}/tools/applications`) .send({ name: "Viewer create app", type: "mcp_http" }), await request(viewerApp) .post(`/api/companies/${company.id}/tools/connections`) .send({ name: "Viewer create connection", transport: "mcp_remote", config: { url: "https://viewer-create.example/mcp" }, }), await request(viewerApp) .patch(`/api/tool-applications/${application.id}`) .send({ name: "Viewer edited app" }), await request(viewerApp).delete( `/api/tool-applications/${application.id}`, ), await request(viewerApp) .patch(`/api/tool-connections/${connection.id}`) .send({ name: "Viewer edited connection" }), await request(viewerApp).delete(`/api/tool-connections/${connection.id}`), await request(viewerApp) .post(`/api/tool-connections/${connection.id}/health-check`) .send({}), await request(viewerApp) .post(`/api/tool-connections/${connection.id}/catalog/refresh`) .send({}), ]; for (const res of responses) { expect(res.status).toBe(403); expect(res.body.error).toContain("Viewer access is read-only"); } }); it("does not expose another user's draft connection to a regular member", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const application = await service.createApplication(company.id, { name: "Shared OAuth app", type: "mcp_http", }); const otherDraft = await service.createConnection( company.id, { applicationId: application.id, name: "Other user's draft", transport: "mcp_remote", authKind: "oauth", status: "draft", config: { url: "https://other-draft.example/mcp" }, }, { actorType: "user", actorId: "other-user" }, ); const ownDraft = await service.createConnection( company.id, { applicationId: application.id, name: "Member's draft", transport: "mcp_remote", authKind: "oauth", status: "draft", config: { url: "https://own-draft.example/mcp" }, }, { actorType: "user", actorId: "member-user" }, ); const activeConnection = await service.createConnection( company.id, { applicationId: application.id, name: "Active connection", transport: "mcp_remote", authKind: "oauth", status: "active", enabled: true, config: { url: "https://active.example/mcp" }, }, { actorType: "user", actorId: "other-user" }, ); const memberApp = createRouteApp( db, boardSessionActor(company.id, "member", "member-user"), ); const memberRes = await request(memberApp) .get(`/api/companies/${company.id}/tools/connections`) .expect(200); expect(memberRes.body.connections).toHaveLength(2); expect(memberRes.body.connections).toEqual( expect.arrayContaining([ expect.objectContaining({ id: ownDraft.id }), expect.objectContaining({ id: activeConnection.id }), ]), ); expect(memberRes.body.connections).not.toEqual( expect.arrayContaining([expect.objectContaining({ id: otherDraft.id })]), ); const ownerApp = createRouteApp( db, boardSessionActor(company.id, "owner", "owner-user"), ); const ownerRes = await request(ownerApp) .get(`/api/companies/${company.id}/tools/connections`) .expect(200); expect(ownerRes.body.connections).toEqual( expect.arrayContaining([ expect.objectContaining({ id: otherDraft.id }), expect.objectContaining({ id: ownDraft.id }), expect.objectContaining({ id: activeConnection.id }), ]), ); }); it("keeps direct profile and policy mutation routes viewer-safe", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const service = createTestToolAccessService(db); const profile = await service.createProfile(company.id, { profileKey: `viewer-guarded-profile-${randomUUID()}`, name: "Viewer guarded profile", defaultAction: "deny", }); const entry = await service.addProfileEntry(profile.id, { selectorType: "tool_name", effect: "include", toolName: "read_notes", }); await service.bindProfile( profile.id, { targetType: "agent", targetId: agent.id }, { actorType: "user", actorId: "board" }, ); const [firstPolicy, secondPolicy] = await db .insert(toolPolicies) .values([ { companyId: company.id, name: `Viewer guarded allow ${randomUUID()}`, policyType: "allow", priority: 100, selectors: { toolName: "read_notes" }, }, { companyId: company.id, name: `Viewer guarded block ${randomUUID()}`, policyType: "block", priority: 200, selectors: { toolName: "delete_notes" }, }, ]) .returning(); const viewerApp = createRouteApp( db, boardSessionActor(company.id, "viewer", "viewer-user"), ); await request(viewerApp) .get(`/api/companies/${company.id}/tools/profiles`) .expect(200); await request(viewerApp) .get(`/api/companies/${company.id}/tools/policies`) .expect(200); const responses = [ await request(viewerApp) .post(`/api/companies/${company.id}/tools/profiles`) .send({ profileKey: `viewer-created-profile-${randomUUID()}`, name: "Viewer created profile", defaultAction: "deny", }), await request(viewerApp) .patch(`/api/tool-profiles/${profile.id}`) .send({ name: "Viewer edited profile" }), await request(viewerApp) .post(`/api/tool-profiles/${profile.id}/entries`) .send({ selectorType: "tool_name", effect: "include", toolName: "viewer_tool", }), await request(viewerApp) .patch(`/api/tool-profile-entries/${entry.id}`) .send({ effect: "exclude" }), await request(viewerApp).delete(`/api/tool-profile-entries/${entry.id}`), await request(viewerApp) .post(`/api/companies/${company.id}/tools/profiles/${profile.id}/bind`) .send({ targetType: "agent", targetId: agent.id, priority: 10 }), await request(viewerApp) .post( `/api/companies/${company.id}/tools/profiles/${profile.id}/unbind`, ) .send({ targetType: "agent", targetId: agent.id }), await request(viewerApp) .post(`/api/companies/${company.id}/tools/policies/reorder`) .send({ policyIds: [secondPolicy!.id, firstPolicy!.id] }), await request(viewerApp) .post(`/api/companies/${company.id}/tools/policies`) .send({ name: "Viewer policy", policyType: "allow", selectors: { toolName: "viewer_tool" }, }), await request(viewerApp) .post( `/api/companies/${company.id}/tools/policies/${firstPolicy!.id}/duplicate`, ) .send({ name: "Viewer policy copy" }), await request(viewerApp) .patch(`/api/companies/${company.id}/tools/policies/${firstPolicy!.id}`) .send({ enabled: false }), await request(viewerApp).delete( `/api/companies/${company.id}/tools/policies/${firstPolicy!.id}`, ), await request(viewerApp) .post( `/api/companies/${company.id}/tools/action-requests/${randomUUID()}/trust-rule`, ) .send({ name: "Viewer trust rule" }), await request(viewerApp) .post( `/api/companies/${company.id}/tools/trust-rules/${firstPolicy!.id}/revoke`, ) .send({ reason: "viewer revoke" }), await request(viewerApp).post( `/api/companies/${company.id}/tools/examples/safe-read-only-todo-kv/install`, ), ]; for (const res of responses) { expect(res.status).toBe(403); expect(res.body.error).toContain("Viewer access is read-only"); } }); it("deletes an application with zero connections and records activity", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const app = createRouteApp(db); const application = await service.createApplication(company.id, { name: "Deletable app", type: "mcp_http", }); const res = await request(app).delete( `/api/tool-applications/${application.id}`, ); expect(res.status).toBe(200); expect(res.body).toMatchObject({ id: application.id, name: "Deletable app", }); const remaining = await db .select() .from(toolApplications) .where(eq(toolApplications.id, application.id)); expect(remaining).toHaveLength(0); const activities = await db .select() .from(activityLog) .where(eq(activityLog.entityId, application.id)); expect(activities).toEqual([ expect.objectContaining({ action: "tool_application.deleted", companyId: company.id, details: expect.objectContaining({ name: "Deletable app", type: "mcp_http", }), }), ]); }); it("returns 409 and keeps the application when it still has connections", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const app = createRouteApp(db); const connection = await service.createConnection(company.id, { name: "Guarded connection", transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, }); const res = await request(app).delete( `/api/tool-applications/${connection.applicationId}`, ); expect(res.status).toBe(409); expect(String(res.body.error)).toMatch(/connection/i); const remaining = await db .select() .from(toolApplications) .where(eq(toolApplications.id, connection.applicationId)); expect(remaining).toHaveLength(1); }); it("archives the application when its last connection is removed", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const app = createRouteApp(db); const connection = await service.createConnection(company.id, { name: "Single connection", transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, status: "active", enabled: true, }); const res = await request(app).delete( `/api/tool-connections/${connection.id}`, ); expect(res.status).toBe(200); expect(res.body).toMatchObject({ id: connection.id, status: "archived", enabled: false, }); const [application] = await db .select() .from(toolApplications) .where(eq(toolApplications.id, connection.applicationId)); expect(application).toMatchObject({ status: "archived" }); expect(application?.archivedAt).toBeInstanceOf(Date); const activities = await db .select() .from(activityLog) .where(eq(activityLog.companyId, company.id)); expect(activities).toEqual( expect.arrayContaining([ expect.objectContaining({ action: "tool_connection.archived", entityId: connection.id, }), expect.objectContaining({ action: "tool_application.archived", entityId: connection.applicationId, details: expect.objectContaining({ reason: "last_connection_removed", }), }), ]), ); }); it("keeps the application active when another connection remains", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const app = createRouteApp(db); const application = await service.createApplication(company.id, { name: "Shared app", type: "mcp_http", }); const first = await service.createConnection(company.id, { applicationId: application.id, name: "First connection", transport: "mcp_remote", config: { url: "https://one.example/mcp" }, status: "active", enabled: true, }); await service.createConnection(company.id, { applicationId: application.id, name: "Second connection", transport: "mcp_remote", config: { url: "https://two.example/mcp" }, status: "active", enabled: true, }); const res = await request(app).delete(`/api/tool-connections/${first.id}`); expect(res.status).toBe(200); const [remainingApplication] = await db .select() .from(toolApplications) .where(eq(toolApplications.id, application.id)); expect(remainingApplication).toMatchObject({ status: "active", archivedAt: null, }); const activities = await db .select() .from(activityLog) .where(eq(activityLog.entityId, application.id)); expect( activities.some( (activity) => activity.action === "tool_application.archived", ), ).toBe(false); }); it("keeps normalized connection UIDs unique", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const firstApplication = await service.createApplication(company.id, { name: "First UID app", type: "mcp_http", }); const secondApplication = await service.createApplication(company.id, { name: "Second UID app", type: "mcp_http", }); const first = await service.createConnection(company.id, { applicationId: firstApplication.id, name: "Foo Bar", transport: "mcp_remote", config: { url: "https://one.example/mcp" }, }); const second = await service.createConnection(company.id, { applicationId: secondApplication.id, name: "foo-bar", transport: "mcp_remote", config: { url: "https://two.example/mcp" }, }); expect(first.uid).not.toBe(second.uid); expect(first.uid).toMatch(/\/foo-bar-[0-9a-f]{8}$/); expect(second.uid).toMatch(/\/foo-bar-[0-9a-f]{8}$/); }); it("fails closed at the database when a connection races an application delete (no silent cascade)", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connection = await service.createConnection(company.id, { name: "Racy connection", transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, }); // Simulate the delete-vs-create race: skip the endpoint's "any connections?" pre-check and // issue the raw DELETE it would run afterwards, standing in for a connection created in the // gap. Under the old ON DELETE CASCADE schema this silently removed the linked connection; // the hardened ON DELETE NO ACTION FK must reject it so the delete can never become an // implicit cascade. await expect( db .delete(toolApplications) .where(eq(toolApplications.id, connection.applicationId)), ).rejects.toThrow(); const remainingApp = await db .select() .from(toolApplications) .where(eq(toolApplications.id, connection.applicationId)); const remainingConnection = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connection.id)); expect(remainingApp).toHaveLength(1); expect(remainingConnection).toHaveLength(1); }); it("still cascades application + connection deletes when the owning company is removed", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connection = await service.createConnection(company.id, { name: "Company-scoped connection", transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, }); // NO ACTION (not RESTRICT) must keep the company teardown cascade intact: deleting the // company cascades to both tool_applications and tool_connections in one statement, and the // end-of-statement FK check passes because the connection is already gone. RESTRICT would // abort this delete mid-cascade. await db.delete(companies).where(eq(companies.id, company.id)); const remainingApp = await db .select() .from(toolApplications) .where(eq(toolApplications.id, connection.applicationId)); const remainingConnection = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connection.id)); expect(remainingApp).toHaveLength(0); expect(remainingConnection).toHaveLength(0); }); it("returns 404 for cross-company application deletes and missing applications", async () => { const allowedCompany = await createCompany(db); const otherCompany = await createCompany(db); const application = await createTestToolAccessService(db).createApplication( otherCompany.id, { name: "Other company app", type: "mcp_http", }, ); const app = createRouteApp(db, { type: "board", userId: "member-user", userName: "Member User", userEmail: null, companyIds: [allowedCompany.id], memberships: [ { companyId: allowedCompany.id, membershipRole: "owner", status: "active", }, ], isInstanceAdmin: false, source: "session", }); const forbiddenRes = await request(app).delete( `/api/tool-applications/${application.id}`, ); const missingRes = await request(createRouteApp(db)).delete( `/api/tool-applications/${randomUUID()}`, ); expect(forbiddenRes.status).toBe(404); expect(missingRes.status).toBe(404); const stillThere = await db .select() .from(toolApplications) .where(eq(toolApplications.id, application.id)); expect(stillThere).toHaveLength(1); }); it("links run tool decisions to invocations, audit events, and pending action requests", async () => { const company = await createCompany(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: `Tool runner ${randomUUID()}`, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const [issue] = await db .insert(issues) .values({ companyId: company.id, title: `Tool approval ${randomUUID()}`, status: "in_progress", }) .returning(); const [run] = await db .insert(heartbeatRuns) .values({ companyId: company.id, agentId: agent.id, invocationSource: "assignment", status: "running", }) .returning(); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "Governed tools", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "Remote MCP", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://example.invalid/mcp" }, }) .returning(); const [catalogEntry] = await db .insert(toolCatalogEntries) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "send_email", toolName: "send_email", riskLevel: "write", versionHash: randomUUID(), schemaHash: randomUUID(), }) .returning(); const [invocation] = await db .insert(toolInvocations) .values({ companyId: company.id, actorType: "agent", actorId: agent.id, agentId: agent.id, issueId: issue.id, runId: run.id, applicationId: application.id, connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "send_email", argumentsHash: "abc123", argumentsSummary: { summary: '{"to":"redacted"}', sha256: "abc123", sizeBytes: 18, }, policyDecision: "require_approval", approvalState: "pending", status: "awaiting_approval", }) .returning(); const [interaction] = await db .insert(issueThreadInteractions) .values({ companyId: company.id, issueId: issue.id, kind: "request_confirmation", status: "pending", continuationPolicy: "wake_assignee_on_accept", title: "Approve tool action", summary: "send_email requires approval.", createdByAgentId: agent.id, payload: { version: 1, prompt: "Approve send_email?", acceptLabel: "Approve action", rejectLabel: "Reject action", target: { type: "custom", key: "tool-action:test", revisionId: "abc123", label: "send_email", }, }, }) .returning(); const [actionRequest] = await db .insert(toolActionRequests) .values({ companyId: company.id, invocationId: invocation.id, issueId: issue.id, interactionId: interaction.id, status: "pending", canonicalArgumentsHash: "abc123", canonicalArgumentsSummary: { summary: '{"to":"redacted"}', sha256: "abc123", sizeBytes: 18, }, previewMarkdown: "Tool: `send_email`", requestedByAgentId: agent.id, }) .returning(); const [auditEvent] = await db .insert(toolCallEvents) .values({ companyId: company.id, eventType: "approval_requested", actorType: "agent", actorId: agent.id, agentId: agent.id, runId: run.id, issueId: issue.id, applicationId: application.id, connectionId: connection.id, catalogEntryId: catalogEntry.id, invocationId: invocation.id, actionRequestId: actionRequest.id, toolName: "send_email", decision: "require_approval", outcome: "pending", reasonCode: "requires_approval_policy", requestHash: "abc123", requestSummary: { summary: '{"to":"redacted"}', sha256: "abc123", sizeBytes: 18, }, metadata: { interactionId: interaction.id }, }) .returning(); const lookup = await createTestToolAccessService(db).getRunDecisionLookup( company.id, run.id, ); expect(lookup).toMatchObject({ runId: run.id, decisions: [ { invocation: expect.objectContaining({ id: invocation.id, runId: run.id, toolName: "send_email", }), actionRequest: expect.objectContaining({ id: actionRequest.id, status: "pending", }), latestAuditEvent: expect.objectContaining({ id: auditEvent.id, actionRequestId: actionRequest.id, }), decision: "require_approval", reasonCode: "requires_approval_policy", pendingAction: expect.objectContaining({ actionRequestId: actionRequest.id, interactionId: interaction.id, previewMarkdown: "Tool: `send_email`", }), }, ], }); }); it("enriches connection activity with issue and approval resolver context", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: "CodexCoder", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "GitHub", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "GitHub", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://github.example/mcp" }, transportConfig: { url: "https://github.example/mcp" }, }) .returning(); const [issue] = await db .insert(issues) .values({ companyId: company.id, title: "Fix app connection copy", status: "in_progress", identifier: "PAP-10912", assigneeAgentId: agent.id, }) .returning(); const [run] = await db .insert(heartbeatRuns) .values({ companyId: company.id, agentId: agent.id, invocationSource: "assignment", status: "running", startedAt: new Date("2026-06-12T10:00:00Z"), }) .returning(); const [catalogEntry] = await db .insert(toolCatalogEntries) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, name: "mark_done", toolName: "mark_done", title: "Mark done", riskLevel: "write", isWrite: true, status: "active", versionHash: "v1", schemaHash: "s1", }) .returning(); const [invocation] = await db .insert(toolInvocations) .values({ companyId: company.id, actorType: "agent", actorId: agent.id, agentId: agent.id, issueId: issue.id, runId: run.id, applicationId: application.id, connectionId: connection.id, catalogEntryId: catalogEntry.id, toolName: "Mark done", policyDecision: "require_approval", approvalState: "approved", status: "completed", }) .returning(); await db.insert(authUsers).values({ id: "board-user", name: "Dotta", email: "dotta@example.com", emailVerified: true, createdAt: new Date("2026-06-12T09:00:00Z"), updatedAt: new Date("2026-06-12T09:00:00Z"), }); const [actionRequest] = await db .insert(toolActionRequests) .values({ companyId: company.id, invocationId: invocation.id, issueId: issue.id, status: "approved", canonicalArgumentsHash: "abc123", canonicalArgumentsSummary: { summary: "{}", sha256: "abc123", sizeBytes: 2, }, requestedByAgentId: agent.id, resolvedByUserId: "board-user", resolvedAt: new Date("2026-06-12T10:05:00Z"), }) .returning(); await db.insert(toolCallEvents).values([ { companyId: company.id, eventType: "call_completed", actorType: "agent", actorId: agent.id, agentId: agent.id, runId: run.id, issueId: issue.id, applicationId: application.id, connectionId: connection.id, catalogEntryId: catalogEntry.id, invocationId: invocation.id, toolName: "Get value", decision: "allow", outcome: "success", createdAt: new Date("2026-06-12T10:04:00Z"), }, { companyId: company.id, eventType: "approval_resolved", actorType: "agent", actorId: agent.id, agentId: agent.id, runId: run.id, issueId: issue.id, applicationId: application.id, connectionId: connection.id, catalogEntryId: catalogEntry.id, invocationId: invocation.id, actionRequestId: actionRequest.id, toolName: "Mark done", decision: "require_approval", outcome: "success", createdAt: new Date("2026-06-12T10:06:00Z"), }, ]); const activity = await service.listConnectionActivity( connection.id, company.id, 10, ); expect(activity.events.map((event) => event.eventType)).toEqual([ "approval_resolved", "call_completed", ]); expect(activity.issues[issue.id]).toEqual({ identifier: "PAP-10912", title: "Fix app connection copy", }); expect(activity.actionRequests[actionRequest.id]).toEqual({ status: "approved", resolverDisplayName: "Dotta", resolvedByAgentId: null, resolvedByUserId: "board-user", }); }); it("surfaces connection lifecycle events on the activity timeline", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const [agent] = await db .insert(agents) .values({ companyId: company.id, name: "CodexCoder", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, }) .returning(); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "Google Sheets", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "Google Sheets (stdio smoke)", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://sheets.example/mcp" }, transportConfig: { url: "https://sheets.example/mcp" }, }) .returning(); await db.insert(authUsers).values({ id: "lifecycle-user", name: "Dotta", email: "dotta@example.com", emailVerified: true, createdAt: new Date("2026-06-12T09:00:00Z"), updatedAt: new Date("2026-06-12T09:00:00Z"), }); await db.insert(activityLog).values([ { companyId: company.id, actorType: "user", actorId: "lifecycle-user", action: "tool_app.connected", entityType: "tool_connection", entityId: connection.id, details: { galleryKey: "google-sheets" }, createdAt: new Date("2026-06-12T10:00:00Z"), }, { companyId: company.id, actorType: "user", actorId: "lifecycle-user", action: "tool_connection.updated", entityType: "tool_connection", entityId: connection.id, details: { lifecycle: "paused", enabled: false }, createdAt: new Date("2026-06-12T10:01:00Z"), }, { companyId: company.id, actorType: "user", actorId: "lifecycle-user", action: "tool_connection.updated", entityType: "tool_connection", entityId: connection.id, details: { lifecycle: "allowlist_changed", added: 2, removed: 0, total: 2, }, createdAt: new Date("2026-06-12T10:02:00Z"), }, { // A plain settings update (no lifecycle tag) must stay out of the feed. companyId: company.id, actorType: "user", actorId: "lifecycle-user", action: "tool_connection.updated", entityType: "tool_connection", entityId: connection.id, details: { status: "active", enabled: true }, createdAt: new Date("2026-06-12T10:03:00Z"), }, { companyId: company.id, actorType: "user", actorId: "board", action: "tool_connection.archived", entityType: "tool_connection", entityId: connection.id, details: { transport: "mcp_remote" }, createdAt: new Date("2026-06-12T10:04:00Z"), }, ]); await db.insert(toolAccessAuditEvents).values([ { companyId: company.id, connectionId: connection.id, actorType: "system", action: "tool_connection.catalog_refresh", outcome: "success", details: { discoveredCount: 5, quarantinedCount: 3 }, createdAt: new Date("2026-06-12T10:05:00Z"), }, { // A refresh that quarantined nothing should not appear. companyId: company.id, connectionId: connection.id, actorType: "system", action: "tool_connection.catalog_refresh", outcome: "success", details: { discoveredCount: 5, quarantinedCount: 0 }, createdAt: new Date("2026-06-12T09:59:00Z"), }, ]); const activity = await service.listConnectionActivity( connection.id, company.id, 20, ); expect(activity.lifecycleEvents.map((event) => event.type)).toEqual([ "actions_quarantined", "disconnected", "allowlist_changed", "app_paused", "app_connected", ]); const byType = Object.fromEntries( activity.lifecycleEvents.map((event) => [event.type, event]), ); expect(byType.app_connected?.actorDisplayName).toBe("Dotta"); expect(byType.app_paused?.actorDisplayName).toBe("Dotta"); expect(byType.allowlist_changed?.details).toMatchObject({ added: 2, removed: 0, }); expect(byType.disconnected?.actorDisplayName).toBe("The board"); expect(byType.actions_quarantined?.details).toMatchObject({ count: 3 }); }); it("rejects runtime controls for non-local runtime kinds", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "Remote app", type: "mcp_http", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "Remote runtime", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: true, config: { url: "https://fixture.example/mcp" }, transportConfig: { url: "https://fixture.example/mcp" }, }) .returning(); const [slot] = await db .insert(toolRuntimeSlots) .values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, slotKey: `${connection.id}:remote`, ownerScopeType: "connection", ownerScopeId: connection.id, runtimeKind: "mcp_remote", status: "running", reuseKey: connection.id, provider: "paperclip", providerRef: "remote:https://fixture.example/mcp", healthStatus: "ok", }) .returning(); await expect( service.stopRuntimeSlot(company.id, slot.id, { actorType: "user", actorId: "board-user", }), ).rejects.toMatchObject({ status: 422, details: expect.objectContaining({ code: "runtime_control_unsupported", runtimeKind: "mcp_remote", }), }); }); it("summarizes runtime health and flags stale slots plus degraded connections", async () => { const company = await createCompany(db); const generatedAt = new Date("2026-06-06T00:00:00.000Z"); const service = createTestToolAccessService(db, { deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: null, now: () => generatedAt, }); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "Local stdio fixture", type: "mcp_stdio", status: "active", }) .returning(); const [connection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: application.id, name: "Degraded local stdio", uid: `test/${randomUUID()}`, transport: "local_stdio", status: "active", enabled: true, config: { templateId: "paperclip.echo-calculator-time" }, transportConfig: { templateId: "paperclip.echo-calculator-time" }, healthStatus: "missing_secret", healthMessage: "A configured credential secret could not be resolved.", }) .returning(); const staleAt = new Date(generatedAt.getTime() - 10 * 60 * 1000); await db.insert(toolRuntimeSlots).values({ companyId: company.id, applicationId: application.id, connectionId: connection.id, slotKey: `${connection.id}:paperclip.echo-calculator-time`, ownerScopeType: "connection", ownerScopeId: connection.id, runtimeKind: "local_stdio", status: "running", reuseKey: connection.id, provider: "paperclip", providerRef: "local-stdio:test-host:slot", commandTemplateKey: "paperclip.echo-calculator-time", healthStatus: "ok", startedAt: staleAt, lastUsedAt: staleAt, updatedAt: staleAt, }); await db.insert(toolAccessAuditEvents).values([ { companyId: company.id, action: "runtime_deferred", outcome: "failure", reasonCode: "runtime_host_capacity_exhausted", details: { durationMs: 250 }, createdAt: generatedAt, }, { companyId: company.id, action: "runtime_restart_suppressed", outcome: "failure", reasonCode: "runtime_restart_suppressed", details: {}, createdAt: generatedAt, }, ]); await db.insert(toolCallEvents).values([ { companyId: company.id, eventType: "call_failed", outcome: "timeout", toolName: "mcp-stdio-fixture:increment_counter", createdAt: generatedAt, }, { companyId: company.id, eventType: "call_completed", outcome: "success", toolName: "mcp-stdio-fixture:runtime_status", createdAt: generatedAt, }, ]); const health = await service.getRuntimeHealth(company.id); expect(health.status).toBe("critical"); expect(health.supportMatrix.localStdio.supported).toBe(false); expect(health.metrics).toMatchObject({ activeSlots: 1, runningSlots: 1, stuckRunningSlots: 1, capacityDeferralsLastHour: 1, restartSuppressionsLastHour: 1, toolCallsLastHour: 2, toolTimeoutsLastHour: 1, timeoutRateLastHour: 50, degradedConnections: 1, localStdioConnections: 1, auditWriteFailuresLastHour: 0, }); expect(health.alerts.map((alert) => alert.name)).toEqual( expect.arrayContaining([ "mcp_runtime_stuck_running_slot", "mcp_runtime_restart_storm", "mcp_runtime_connection_health_degraded", ]), ); expect( health.recommendations.find( (alert) => alert.name === "mcp_runtime_audit_write_failures", ), ).toMatchObject({ status: "ok", observed: "0 audit write failure(s) in 1 hour.", }); }); it("fires runtime health from the durable audit-write failure counter", async () => { const company = await createCompany(db); const generatedAt = new Date("2026-06-06T00:00:00.000Z"); const service = createTestToolAccessService(db, { now: () => generatedAt }); await db.insert(toolRuntimeMetricCounters).values({ companyId: company.id, metric: "audit_write_failed", bucketStartAt: new Date(generatedAt.getTime() - 5 * 60 * 1000), count: 2, createdAt: generatedAt, updatedAt: generatedAt, }); const health = await service.getRuntimeHealth(company.id); expect(health.metrics.auditWriteFailuresLastHour).toBe(2); expect( health.alerts.find( (alert) => alert.name === "mcp_runtime_audit_write_failures", ), ).toMatchObject({ severity: "critical", status: "firing", observed: "2 audit write failure(s) in 1 hour.", }); }); it("does not degrade runtime health for draft or not-enabled setup connections", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db, { now: () => new Date("2026-06-06T00:00:00.000Z"), }); const [application] = await db .insert(toolApplications) .values({ companyId: company.id, name: "Setup apps", type: "mcp_http", status: "active", }) .returning(); await db.insert(toolConnections).values([ { companyId: company.id, applicationId: application.id, name: "Imported draft", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "draft", enabled: false, config: { url: "https://draft.example/mcp" }, transportConfig: { url: "https://draft.example/mcp" }, healthStatus: "missing_secret", healthMessage: "Needs setup before first use.", }, { companyId: company.id, applicationId: application.id, name: "OAuth connected, not enabled", uid: `test/${randomUUID()}`, transport: "mcp_remote", status: "active", enabled: false, config: { url: "https://not-enabled.example/mcp" }, transportConfig: { url: "https://not-enabled.example/mcp" }, healthStatus: "missing_secret", healthMessage: "Catalog access has not been enabled.", }, ]); const health = await service.getRuntimeHealth(company.id); expect(health.status).toBe("ok"); expect(health.metrics).toMatchObject({ activeConnections: 0, disabledConnections: 0, degradedConnections: 0, }); expect(health.alerts.map((alert) => alert.name)).not.toContain( "mcp_runtime_connection_health_degraded", ); expect( health.recommendations.find( (alert) => alert.name === "mcp_runtime_connection_health_degraded", ), ).toMatchObject({ status: "ok", observed: "0 degraded connection(s), 0 disabled connection(s).", }); }); it("rejects enabled local stdio connections in public hosted mode without a trusted runtime host", async () => { const company = await createCompany(db); const hostedService = createTestToolAccessService(db, { deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: null, }); await expect( hostedService.createConnection(company.id, { name: "Hosted local stdio", transport: "local_stdio", config: { templateId: "paperclip.echo-calculator-time" }, enabled: true, status: "active", }), ).rejects.toMatchObject({ status: 422, message: expect.stringContaining("cannot be enabled"), }); const trustedService = createTestToolAccessService(db, { deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: "trusted-worker-1", }); await expect( trustedService.createConnection(company.id, { name: "Trusted hosted local stdio", transport: "local_stdio", config: { templateId: "paperclip.echo-calculator-time" }, enabled: true, status: "active", }), ).resolves.toMatchObject({ transport: "local_stdio", enabled: true, }); }); it("previews mcp.json imports as draft managed connection records without carrying raw header values", async () => { const company = await createCompany(db); const preview = await createTestToolAccessService(db).previewMcpJsonImport({ mcpJson: { mcpServers: { github: { url: "https://mcp.example/github", headers: { Authorization: "Bearer should-not-be-stored" }, }, local: { command: "npx", args: ["-y", "@example/local-mcp"], }, }, }, }); expect(company.id).toBeTruthy(); expect(JSON.stringify(preview)).not.toContain("should-not-be-stored"); expect(preview.drafts).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "github", transport: "mcp_remote", status: "draft", config: { url: "https://mcp.example/github" }, warnings: [expect.stringContaining("Paperclip secret")], }), expect.objectContaining({ name: "local", transport: "local_stdio", status: "draft", config: { importedCommand: "npx", importedArgs: ["-y", "@example/local-mcp"], }, warnings: [expect.stringContaining("approved Paperclip template")], }), ]), ); }); it("fails closed when credential secrets cannot be resolved and writes value-free audit", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connection = await service.createConnection(company.id, { name: "Secret-backed remote", transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, enabled: true, status: "active", }); await db .update(toolConnections) .set({ credentialRefs: [ { name: "authorization", secretId: randomUUID(), version: "latest", placement: "header", key: "Authorization", prefix: "Bearer ", }, ], }) .where(eq(toolConnections.id, connection.id)); await expect( service.checkHealth(connection.id, { actorType: "user", actorId: "board", }), ).rejects.toMatchObject({ status: 422, details: expect.objectContaining({ code: "secret_missing" }), }); const [updatedConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connection.id)); const [audit] = await db .select() .from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.action, "tool_connection.health_check")); expect(updatedConnection).toMatchObject({ healthStatus: "missing_secret", healthMessage: "A configured credential secret could not be resolved.", }); expect(audit).toMatchObject({ action: "tool_connection.health_check", outcome: "failure", reasonCode: "secret_missing", details: { status: "missing_secret", transport: "mcp_remote" }, }); expect(JSON.stringify(audit)).not.toContain("Bearer "); expect(JSON.stringify(audit)).not.toContain("Authorization"); }); it("sweeps enabled active connection health and records failing connections", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("revoked token")); const [chatApplication] = await db .insert(toolApplications) .values({ companyId: company.id, applicationKey: `chat:slack:${randomUUID()}`, name: "Slack chat", type: "chat", status: "active", metadata: { sourceTemplateKey: "slack", purpose: "channel" }, }) .returning(); const [chatConnection] = await db .insert(toolConnections) .values({ companyId: company.id, applicationId: chatApplication!.id, name: "Slack agent", uid: `chat-slack-${randomUUID()}`, connectionKind: "managed", connectionPurpose: "channel", ownership: "customer", transport: "chat_sdk", authKind: "api_key", credentialPolicy: "shared", status: "active", enabled: true, config: { provider: "slack" }, transportConfig: {}, healthStatus: "ok", healthMessage: "Slack webhook healthy.", healthCheckedAt: new Date(0), lastHealthAt: new Date(0), }) .returning(); const [pluginApplication] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: `paperclip_plugin:fixture-${randomUUID()}`, name: "Plugin placeholder", type: "paperclip_plugin", status: "active", metadata: { source: "plugin_backfill" }, }).returning(); const [pluginConnection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: pluginApplication!.id, name: "Plugin placeholder", uid: `plugin-${randomUUID()}`, connectionKind: "managed", transport: "mcp_remote", status: "active", enabled: true, config: { type: "paperclip_plugin" }, transportConfig: { type: "paperclip_plugin" }, healthStatus: "ok", healthCheckedAt: null, }).returning(); const connection = await service.createConnection(company.id, { name: "Swept remote", transport: "mcp_remote", config: { url: "https://fixture.example/mcp" }, enabled: true, status: "active", }); const sweep = await service.sweepConnectionHealth({ staleAfterMs: 0, limit: 1 }); const [updatedConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, connection.id)); const [untouchedChatConnection] = await db .select() .from(toolConnections) .where(eq(toolConnections.id, chatConnection!.id)); const [untouchedPlugin] = await db.select().from(toolConnections) .where(eq(toolConnections.id, pluginConnection!.id)); expect(untouchedPlugin).toMatchObject({ enabled: true, healthStatus: "ok", healthCheckedAt: null }); expect(sweep).toMatchObject({ checked: 1, healthy: 0, failed: 1, failedConnectionIds: [connection.id], }); expect(updatedConnection).toMatchObject({ healthStatus: "error", healthMessage: "revoked token", lastError: "revoked token", }); expect(untouchedChatConnection).toMatchObject({ healthStatus: "ok", healthMessage: "Slack webhook healthy.", lastError: null, healthCheckedAt: new Date(0), lastHealthAt: new Date(0), }); }); it("enriches listConnections with lastUsedAt from the most recent tool-call event", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const used = await service.createConnection(company.id, { name: "Used remote", transport: "mcp_remote", config: { url: "https://used.example/mcp" }, enabled: true, status: "active", }); const unused = await service.createConnection(company.id, { name: "Unused remote", transport: "mcp_remote", config: { url: "https://unused.example/mcp" }, enabled: true, status: "active", }); const older = new Date("2026-06-01T00:00:00.000Z"); const newest = new Date("2026-06-09T12:30:00.000Z"); await db.insert(toolCallEvents).values([ { companyId: company.id, eventType: "call_completed", connectionId: used.id, toolName: "search_notes", outcome: "success", createdAt: older, }, { companyId: company.id, eventType: "call_completed", connectionId: used.id, toolName: "search_notes", outcome: "success", createdAt: newest, }, ]); const connections = await service.listConnections(company.id); const usedRow = connections.find((connection) => connection.id === used.id); const unusedRow = connections.find( (connection) => connection.id === unused.id, ); expect(new Date(usedRow!.lastUsedAt!).toISOString()).toBe( newest.toISOString(), ); expect(unusedRow!.lastUsedAt).toBeNull(); }); it("syncs installs without widening action access or calling the remote tool", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); const fetchMock = vi.spyOn(globalThis, "fetch"); const app = createRouteApp( db, undefined, createToolGatewayService(db, { toolActionSigningSecret: "test-secret", }), ); const put = await request(app) .put(`/api/tool-connections/${connection.id}/installs`) .send({ installs: [{ targetType: "agent", targetId: agent.id }] }); expect(put.status).toBe(200); expect(put.body).toMatchObject({ connectionId: connection.id, installs: [{ targetType: "agent", targetId: agent.id }], }); const [install] = await db.select().from(toolConnectionInstalls); expect(install).toMatchObject({ companyId: company.id, connectionId: connection.id, targetId: agent.id, }); const profile = await db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${connection.id}`)); expect(profile).toHaveLength(1); const binding = await db .select() .from(toolProfileBindings) .where( and( eq(toolProfileBindings.profileId, profile[0]!.id), eq(toolProfileBindings.targetType, "agent"), eq(toolProfileBindings.targetId, agent.id), ), ); expect(binding).toHaveLength(1); const events = await db .select() .from(activityLog) .where(eq(activityLog.action, "tool_connection.install_access_extended")); expect(events).toHaveLength(1); const effective = await createTestToolAccessService( db, ).getEffectiveProfilesForAgent(company.id, agent.id); expect(effective.installedConnections.map((item) => item.id)).toEqual([ connection.id, ]); expect( effective.allowedTools.some( (tool) => tool.connectionId === connection.id, ), ).toBe(false); const deniedCall = await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" }, }) .expect(200); expect(deniedCall.body).toMatchObject({ decision: "off", error: { reasonCode: "deny_default" }, }); expect(fetchMock).not.toHaveBeenCalled(); const get = await request(app).get( `/api/tool-connections/${connection.id}`, ); expect(get.status).toBe(200); expect(get.body.installs).toEqual( expect.arrayContaining([ expect.objectContaining({ targetType: "agent", targetId: agent.id }), ]), ); }); it("removes the install-derived binding when an agent is uninstalled, and keeps an operator-authored one", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const other = await createAgent(db, company.id); const { connection } = await createRemoteToolFixture(db, company.id); const app = createRouteApp( db, undefined, createToolGatewayService(db, { toolActionSigningSecret: "test-secret", }), ); await request(app) .put(`/api/tool-connections/${connection.id}/installs`) .send({ installs: [{ targetType: "agent", targetId: agent.id }] }) .expect(200); const [profile] = await db .select() .from(toolProfiles) .where(eq(toolProfiles.profileKey, `app:${connection.id}`)); expect(profile).toBeDefined(); const bindingsFor = async (targetId: string) => db .select() .from(toolProfileBindings) .where( and( eq(toolProfileBindings.profileId, profile!.id), eq(toolProfileBindings.targetType, "agent"), eq(toolProfileBindings.targetId, targetId), ), ); expect(await bindingsFor(agent.id)).toHaveLength(1); // A binding the operator authored through the access model, not through an // install. Uninstalling must not touch it. await db.insert(toolProfileBindings).values({ companyId: company.id, profileId: profile!.id, targetType: "agent", targetId: other.id, priority: 100, metadata: { source: "operator" }, }); await request(app) .put(`/api/tool-connections/${connection.id}/installs`) .send({ installs: [] }) .expect(200); // The install row is gone, so the agent can no longer reach the connection. expect( await db .select() .from(toolConnectionInstalls) .where(eq(toolConnectionInstalls.connectionId, connection.id)), ).toHaveLength(0); // The binding the install created is gone too, so the permission state cannot // report an agent the operator already removed. expect(await bindingsFor(agent.id)).toHaveLength(0); // The operator-authored binding survives. expect(await bindingsFor(other.id)).toHaveLength(1); const effective = await createTestToolAccessService( db, ).getEffectiveProfilesForAgent(company.id, agent.id); expect(effective.installedConnections.map((item) => item.id)).not.toContain( connection.id, ); }); it("limits connection configuration to the creator or a manager with role defaults", async () => { const company = await createCompany(db); const creator = boardSessionActor( company.id, "member", `creator-${randomUUID()}`, ); const otherMember = boardSessionActor( company.id, "member", `member-${randomUUID()}`, ); const admin = boardSessionActor( company.id, "admin", `admin-${randomUUID()}`, ); await grantBoardUser(db, company.id, creator.userId!, [], "member"); await grantBoardUser(db, company.id, otherMember.userId!, [], "member"); await grantBoardUser(db, company.id, admin.userId!, [], "admin"); const connection = await createTestToolAccessService(db).createConnection( company.id, { name: "Creator-owned connection", transport: "mcp_remote", config: { url: PUBLIC_MCP_FIXTURE_URL }, }, { actorType: "user", actorId: creator.userId! }, ); const denied = await request(createRouteApp(db, otherMember)) .patch(`/api/tool-connections/${connection.id}`) .send({ name: "Member edit" }); expect(denied.status).toBe(403); expect(denied.body.error).toContain( "connection creator or a connection manager", ); await request(createRouteApp(db, creator)) .patch(`/api/tool-connections/${connection.id}`) .send({ name: "Creator edit" }) .expect(200); await request(createRouteApp(db, admin)) .patch(`/api/tool-connections/${connection.id}`) .send({ name: "Admin edit" }) .expect(200); const adminGrants = await db .select() .from(principalPermissionGrants) .where(eq(principalPermissionGrants.principalId, admin.userId!)); expect(adminGrants).toEqual([]); }); it("keeps agent installs self-serve for members with connection access and audits changes", async () => { const company = await createCompany(db); const creator = boardSessionActor( company.id, "member", `creator-${randomUUID()}`, ); const member = boardSessionActor( company.id, "member", `member-${randomUUID()}`, ); await grantBoardUser(db, company.id, creator.userId!, [], "member"); await grantBoardUser( db, company.id, member.userId!, ["agents:configure"], "member", ); const agent = await createAgent(db, company.id); const connection = await createTestToolAccessService(db).createConnection( company.id, { name: "Shared organization connection", transport: "mcp_remote", config: { url: PUBLIC_MCP_FIXTURE_URL }, }, { actorType: "user", actorId: creator.userId! }, ); const app = createRouteApp(db, member); await request(app) .put(`/api/tool-connections/${connection.id}/installs`) .send({ installs: [{ targetType: "agent", targetId: agent.id }] }) .expect(200); await request(app) .put(`/api/tool-connections/${connection.id}/installs`) .send({ installs: [] }) .expect(200); const audits = await db .select() .from(toolAccessAuditEvents) .where(eq(toolAccessAuditEvents.action, "connection_installs.changed")); expect(audits).toHaveLength(2); expect( audits.every( (audit) => audit.actorType === "user" && audit.actorId === member.userId, ), ).toBe(true); }); }); describe("classifyRisk", () => { const risk = (name: string, annotations?: Record) => classifyRisk({ name, annotations }); it("classifies unprefixed write verbs as write", () => { expect(risk("create_widget")).toBe("write"); expect(risk("update_zap")).toBe("write"); expect(risk("send_message")).toBe("write"); expect(risk("set_value")).toBe("write"); }); it("classifies namespaced write verbs as write (PAP-10902)", () => { // Real MCP servers return colon-namespaced names that the old leading-anchor // regex fell through to "read", pre-enabling writes in the Connect wizard. expect(risk("qa10864:create_widget")).toBe("write"); expect(risk("github:create_issue")).toBe("write"); expect(risk("notion:update_page")).toBe("write"); expect(risk("linear:create_issue")).toBe("write"); }); it("classifies camelCase write verbs as write", () => { expect(risk("slack:postMessage")).toBe("write"); expect(risk("createIssue")).toBe("write"); }); it("classifies namespaced destructive verbs as destructive", () => { expect(risk("delete_widget")).toBe("destructive"); expect(risk("github:delete_repo")).toBe("destructive"); expect(risk("notion:remove_page")).toBe("destructive"); expect(risk("cms:unpublish_post")).toBe("destructive"); }); it("classifies read verbs and noise as read", () => { expect(risk("search_notes")).toBe("read"); expect(risk("github:list_issues")).toBe("read"); expect(risk("getUser")).toBe("read"); expect(risk("echo")).toBe("read"); // Verbs embedded mid-word must not trigger (no segment boundary). expect(risk("settings")).toBe("read"); expect(risk("dataset_export")).toBe("read"); }); it("honours explicit annotation hints over name heuristics", () => { expect(risk("list_items", { destructiveHint: true })).toBe("destructive"); expect(risk("list_items", { writeHint: true })).toBe("write"); expect(risk("list_items", { readOnlyHint: false })).toBe("write"); }); it("classifies the reviewed Notion MCP catalog with provider-scoped defaults", () => { const notionRisk = (name: string, annotations?: Record) => classifyRisk({ name, annotations }, "notion"); const readTools = [ "notion-search", "notion-fetch", "notion-query-data-sources", "notion-query-database-view", "notion-query-meeting-notes", "notion-get-comments", "notion-get-teams", "notion-get-users", "notion-get-async-task", ]; const writeTools = [ "notion-create-pages", "notion-update-page", "notion-convert-page-to-skill", "notion-move-pages", "notion-duplicate-page", "notion-create-database", "notion-create-folder", "notion-update-data-source", "notion-create-view", "notion-update-view", "notion-create-comment", ]; for (const toolName of readTools) expect(notionRisk(toolName)).toBe("read"); for (const toolName of writeTools) { expect(notionRisk(toolName, { readOnlyHint: true })).toBe("write"); } expect(notionRisk("notion-delete-page")).toBe("destructive"); expect(classifyRisk({ name: "move_pages" })).toBe("read"); }); it("uses conservative PostHog defaults for unknown and nested-execution tools", () => { expect( classifyRisk( { name: "query_insight", annotations: { readOnlyHint: true } }, "posthog", ), ).toBe("read"); expect(classifyRisk({ name: "brand_new_tool" }, "posthog")).toBe("write"); expect(classifyRisk({ name: "exec" }, "posthog")).toBe("destructive"); }); it("keeps Shopify checkout completion and cancellation behind destructive-action approval", () => { expect(classifyRisk({ name: "cancel_cart" }, "shopify")).toBe( "destructive", ); expect(classifyRisk({ name: "cancel_checkout" }, "shopify")).toBe( "destructive", ); expect(classifyRisk({ name: "complete_checkout" }, "shopify")).toBe( "destructive", ); expect(classifyRisk({ name: "create_cart" }, "shopify")).toBe("write"); }); }); describe("normalizeConnectionMethodConfig", () => { const posthog = getConnectableAppDefinition("posthog")!; const apiKeyMethod = posthog.methods.find( (method) => method.key === "mcp-api-key", )!; const clickhouseMethod = getConnectableAppDefinition("clickhouse")!.methods[0]!; const shopifyMethods = getConnectableAppDefinition("shopify")!.methods; const shopifyMethod = shopifyMethods.find( (method) => method.key === "storefront-mcp", )!; const shopifyUcpMethod = shopifyMethods.find( (method) => method.key === "ucp-commerce", )!; it("builds a concrete Shopify endpoint from the validated store domain", () => { expect( normalizeConnectionMethodConfig(shopifyMethod, { storeDomain: "paperclip-demo.myshopify.com", }), ).toEqual({ values: { storeDomain: "paperclip-demo.myshopify.com" }, url: "https://paperclip-demo.myshopify.com/api/mcp", }); }); it("uses the broad PostHog catalog when optional advanced filters are untouched", () => { expect(normalizeConnectionMethodConfig(apiKeyMethod, {})).toEqual({ values: { readOnly: false, mode: "tools", }, url: "https://mcp.posthog.com/mcp?mode=tools", }); }); it("normalizes and projects PostHog scope without accepting arbitrary config", () => { expect( normalizeConnectionMethodConfig(apiKeyMethod, { projectId: "12345", readOnly: true, features: "insights, error_tracking\ninsights", tools: "query_insight", mode: "tools", }), ).toEqual({ values: { projectId: "12345", readOnly: true, features: "insights,error_tracking", tools: "query_insight", mode: "tools", }, url: "https://mcp.posthog.com/mcp?readonly=true&features=insights%2Cerror_tracking&tools=query_insight&mode=tools", headers: { "x-posthog-project-id": "12345" }, }); expect(() => normalizeConnectionMethodConfig(apiKeyMethod, { projectId: "not-a-project", features: "insights", }), ).toThrow("Pin to project ID has an invalid value"); expect(() => normalizeConnectionMethodConfig(apiKeyMethod, { projectId: "12345", features: "insights", apiKey: "must-not-be-config", }), ).toThrow("Unknown connection setting: apiKey"); expect(() => normalizeConnectionMethodConfig(clickhouseMethod, { serviceId: "service-id\r\nX-Injected: yes", }), ).toThrow("x-service-id"); }); it("builds a tenant-scoped Shopify endpoint from a validated store domain", () => { expect( normalizeConnectionMethodConfig(shopifyMethod, { storeDomain: "rcvbsa-pz.myshopify.com", }), ).toEqual({ values: { storeDomain: "rcvbsa-pz.myshopify.com" }, url: "https://rcvbsa-pz.myshopify.com/api/mcp", }); expect(() => normalizeConnectionMethodConfig(shopifyMethod, { storeDomain: "evil.example.com", }), ).toThrow("Store domain has an invalid value"); expect(() => normalizeConnectionMethodConfig(shopifyMethod, { storeDomain: "shop.myshopify.com@example.com", }), ).toThrow("Store domain has an invalid value"); expect( normalizeConnectionMethodConfig(shopifyUcpMethod, { storeDomain: "rcvbsa-pz.myshopify.com", }), ).toMatchObject({ url: "https://rcvbsa-pz.myshopify.com/api/ucp/mcp", }); expect( projectConnectionMethodToolArguments(shopifyUcpMethod, { catalog: { query: "shirts" }, meta: { caller: "kept", "ucp-agent": { profile: "https://attacker.example/profile.json" }, }, }), ).toEqual({ catalog: { query: "shirts" }, meta: { caller: "kept", "ucp-agent": { profile: "https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json", }, }, }); expect( projectConnectionMethodToolInputSchema(shopifyUcpMethod, { type: "object", required: ["meta", "catalog"], properties: { meta: { type: "object", required: ["ucp-agent", "idempotency-key"], properties: { "ucp-agent": { type: "object" }, "idempotency-key": { type: "string" }, }, }, catalog: { type: "object" }, }, }), ).toEqual({ type: "object", required: ["meta", "catalog"], properties: { meta: { type: "object", required: ["idempotency-key"], properties: { "idempotency-key": { type: "string" }, }, }, catalog: { type: "object" }, }, }); }); });