diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index d5f31236b5e..d6333d362e5 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -717,6 +717,10 @@ describe("connection management with production default agents", () => { expect(await humanRotation.json()).toEqual({ webhookSecret: expect.stringMatching(/^[a-f0-9]{64}$/) }); const [humanRotated] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); expect(humanRotated.credentialSecretRefs).not.toEqual(beforeRotation.credentialSecretRefs); + const rotationState = await f.request("GET", `/chat-endpoints/${endpoint.id}`); + expect(rotationState.body.setup).toMatchObject({ credentialRotationId: expect.any(String), webhookVerifiedAt: null }); + const nextRotation = await f.request("POST", `/chat-endpoints/${endpoint.id}/setup-secret`, {}); + expect(nextRotation.body.requiredUserAction.setupPath).toContain(`afterRotationId=${rotationState.body.setup.credentialRotationId}`); expect(await findSatisfiedToolConnection(server.db, task, rotationIntent)).toBeNull(); // Provider-fixture states separate restored health from new webhook verification. const [rotatedEndpoint] = await server.db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)); diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 6d246cabcc1..4e7f19f6ee0 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -1,5 +1,5 @@ import { connectionManagementService } from "../services/connection-management.js"; -import { connectionIntentService, savedChatSetupPath } from "../services/connection-intents.js"; +import { chatCredentialRotationId, connectionIntentService, savedChatSetupPath } from "../services/connection-intents.js"; import { companies } from "@paperclipai/db"; import { eq } from "drizzle-orm"; import { requiredConnectionUserAction, filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js"; @@ -109,13 +109,15 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { const endpoint = await service.get(endpointId(req)); const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, endpoint.companyId)); const action = options.requireCredentialRotation ? "rotate_credentials" as const : kind === "enter_credentials" || kind === "confirm_identity" ? kind : undefined; - const interactionId = req.actor.type === "agent" && req.actor.runId && endpoint.assignedAgentId === req.actor.agentId + const handoff = req.actor.type === "agent" && req.actor.runId && endpoint.assignedAgentId === req.actor.agentId ? await connectionIntentService(db).requestChatHandoff({ sub: req.actor.agentId!, company_id: endpoint.companyId, run_id: req.actor.runId, responsible_user_id: connectionRequestUserId(req)! }, endpoint.id, { action }) : null; + const interactionId = handoff?.interactionId ?? null; + const previousRotationId = handoff ? handoff.previousRotationId : chatCredentialRotationId(endpoint.setup); return { status: "needs_user_action", interactionId, endpointId: endpoint.id, connectionId: endpoint.connectionId, ...requiredConnectionUserAction(req, { type: "chat_endpoint", id: endpoint.id, companyId: endpoint.companyId }, - savedChatSetupPath(company!.prefix, endpoint, action), reason, kind), + savedChatSetupPath(company!.prefix, endpoint, action, previousRotationId), reason, kind), }; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index e79e40a0cad..2edb199b59d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -835,6 +835,7 @@ const chatEndpointSetupResponseSchema = z webhookUrl: z.string().nullable().optional(), messagingEndpoint: z.string().nullable().optional(), webhookVerifiedAt: z.string().datetime().nullable().optional(), + credentialRotationId: z.string().uuid().nullable().optional(), webhookSecretConfigured: z.boolean().optional(), callbackSurfaces: z .object({ diff --git a/server/src/services/chat-channels/endpoints.ts b/server/src/services/chat-channels/endpoints.ts index b391f44646d..4d09b7b5d85 100644 --- a/server/src/services/chat-channels/endpoints.ts +++ b/server/src/services/chat-channels/endpoints.ts @@ -38,6 +38,7 @@ import type { } from "@paperclipai/shared"; import { isAgentStatusInvokable, + isUuidLike, } from "@paperclipai/shared"; import { @@ -85,6 +86,7 @@ function providerSetupState( const path = endpoint.provider === "speko" ? `/api/voice-webhooks/${endpoint.publicId}/tools` : `/api/chat-webhooks/${endpoint.publicId}/${endpoint.provider}`; const webhookUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : null; const step = endpoint.status === "active" ? "complete" : endpoint.setup.step; + const rotationId: unknown = Reflect.get(endpoint.setup, "credentialRotationId"); switch (endpoint.provider) { case "speko": return { step, providerUrl: "https://platform.speko.ai/", webhookUrl } as const; @@ -103,6 +105,7 @@ function providerSetupState( providerUrl: "https://github.com/settings/installations", webhookUrl, webhookVerifiedAt: endpoint.setup.webhookVerifiedAt ?? null, + credentialRotationId: typeof rotationId === "string" && isUuidLike(rotationId) ? rotationId : null, } as const; case "discord": return { diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index ce680726cb7..3fdabb0d06f 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -72,11 +72,11 @@ export function chatCredentialRotationId(setup: object): string | null { return typeof value === "string" && isUuidLike(value) ? value : null; } -export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string }, action?: SavedChatSetup["action"]) { +export function savedChatSetupPath(prefix: string, endpoint: { id: string; provider: string }, action?: SavedChatSetup["action"], previousRotationId?: string | null) { const query = new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id }); if (action === "enter_credentials" || action === "rotate_credentials") query.set("stage", "credentials"); if (action === "confirm_identity") query.set("stage", "identity"); - if (action === "rotate_credentials") { query.set("reconnect", "1"); query.set("rotateSecret", "1"); } + if (action === "rotate_credentials") { query.set("reconnect", "1"); query.set("rotateSecret", "1"); query.set("afterRotationId", previousRotationId ?? "none"); } return `/${encodeURIComponent(prefix)}/apps/chat/connect?${query}`; } @@ -911,7 +911,7 @@ export function connectionIntentService(db: Db) { addresseeUserId: context.run.responsibleUserId!, idempotencyKey: `chat-${options.action ?? "authentication"}:${context.issue.id}:${context.run.responsibleUserId}:${endpoint.id}${options.action === "rotate_credentials" ? `:${context.run.id}` : ""}`, }); if (interaction.status !== "pending") throw conflict("This setup request has already been resolved. Follow its recorded outcome."); - return interaction.id; + return { interactionId: interaction.id, previousRotationId: connectionIntentPayloadSchema.parse(interaction.payload).savedSetup?.credentialRotation?.previousId ?? null }; } async function setupOptions(interactionId: string, options: { canManageOrganizationGrant?: boolean } = {}): Promise { @@ -929,7 +929,7 @@ export function connectionIntentService(db: Db) { return { version: 1, interaction: loaded.interaction, requestedAgentId: payload.requestingAgentId, existingConnections: [], service: { service: payload.serviceSlug, name: payload.serviceName, description: null, logoUrl: null, methods: [], state: "needs_user_action", connectionId: endpoint.connectionId }, chatSetup: { endpointId: endpoint.id, connectionId: endpoint.connectionId, - path: savedChatSetupPath(company!.prefix, endpoint, payload.savedSetup.action) }, + path: savedChatSetupPath(company!.prefix, endpoint, payload.savedSetup.action, payload.savedSetup.credentialRotation?.previousId) }, }; } const app = await resolveService(payload.serviceSlug, loaded.issue.companyId, loaded.interaction.addresseeUserId!, payload.requestingAgentId, payload.purpose);