Files
PaperClipAI/server/src/services/chat-channels/github/setup-secret.ts
T
DottaandPaperclip 35b6b467df Enforce connection scopes and human ownership in account discovery
Filter AgentMail metadata before credential discovery, keep aggregator settings and inventory under the responsible user, and retain actual-agent audit identity. Return the committed nonsecret generation alongside human-only setup secrets so clients can reconcile a lost replacement response.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 16:52:54 -05:00

355 lines
14 KiB
TypeScript

import type { ConnectionManagementContext } from "../../connection-management.js";
import type { EndpointRow, InternalSetupState } from "../runtime-types.js";
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
import {
randomBytes,
randomUUID,
} from "node:crypto";
import {
and,
eq,
sql,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
chatEndpoints,
companySecretBindings,
toolConnections,
} from "@paperclipai/db";
import type {
ChatEndpointSetupState,
ToolCredentialSecretRef,
} from "@paperclipai/shared";
import {
conflict,
forbidden,
notFound,
unprocessable,
} from "../../../errors.js";
import { logger } from "../../../middleware/logger.js";
import {
logActivity,
} from "../../activity-log.js";
import type { EndpointRecord } from "../runtime-types.js";
type GitHubSetupSecretOptions = {
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null, onCommitted?: (tx: Db) => Promise<void>) => Promise<ToolCredentialSecretRef[]>;
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
invalidateRuntime: (endpointId: string) => Promise<boolean>;
redactError: (error: unknown) => string;
setupSecretActivityLogger?: typeof logActivity;
setupSecretCredentialPersistBarrier?: () => Promise<void>;
setupSecretFinalOwnershipBarrier?: () => Promise<void>;
};
export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOptions) {
const { endpointRecord, withCredentialMutationLease, resolveCredentials, persistCredentials, runtimeGeneration, credentialFingerprint, invalidateRuntime, redactError } = options;
async function generateSetupSecret(
endpointId: string,
actorUserId?: string | null,
managementContext?: ConnectionManagementContext,
) {
await managementContext?.reauthorize();
if (managementContext?.actor.type === "agent") {
throw forbidden("Webhook signing secrets require a human setup handoff");
}
const initial = await endpointRecord(endpointId);
if (!initial) throw notFound("Chat endpoint not found");
if (initial.endpoint.provider !== "github") {
throw unprocessable(
"Setup secret generation is only available for GitHub",
);
}
let committedWebhookSecretRef: ToolCredentialSecretRef | null = null;
return withCredentialMutationLease(
initial.endpoint,
async (credentialLease) => {
// A competing request may have completed while this request waited for
// the durable lease. Reload both endpoint state and credential refs only
// after ownership so each successful rotation starts from its immediate
// predecessor instead of replacing the credential set from a stale read.
const record = await endpointRecord(endpointId);
if (!record) throw notFound("Chat endpoint not found");
const endpoint = record.endpoint;
if (
![
"draft",
"verifying",
"active",
"attention",
"revoked",
"paused",
].includes(endpoint.status)
) {
throw conflict(
"The GitHub webhook secret cannot be rotated in this connection state",
{
code: "chat_endpoint_setup_secret_unavailable",
},
);
}
const webhookSecret = randomBytes(32).toString("hex");
// Rotation must fail closed if any existing credential cannot be resolved.
// Falling back to an empty object here would replace the full credential
// set with only the new webhook secret and strand an otherwise-live App.
const existing = await resolveCredentials(endpoint);
const replacedExistingSecret = record.credentialSecretRefs.some(
(ref) => ref.configPath === "credentials.webhookSecret",
);
const rotatingConfiguredApp =
Boolean(endpoint.providerAccountId || endpoint.botExternalId) ||
record.credentialSecretRefs.some((ref) =>
["credentials.appId", "credentials.privateKey"].includes(
ref.configPath,
),
);
const rotationId = randomUUID();
const auditDetails = {
endpointId: endpoint.id,
provider: endpoint.provider,
rotationId,
rotated: rotatingConfiguredApp,
replacedPrevious: replacedExistingSecret,
};
const writeSetupSecretActivity =
options.setupSecretActivityLogger ?? logActivity;
// This durable intent is the audit boundary. If it cannot be written,
// fail before changing state or refs so retry is safe. Endpoint/tool
// state moves fail-closed before ref replacement. Once refs change, only
// best-effort work remains and the plaintext must still be returned.
await db.transaction(async (tx) => {
await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
await writeSetupSecretActivity(tx as unknown as Db, {
companyId: endpoint.companyId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.setup_secret_rotation_started",
entityType: "tool_connection",
entityId: endpoint.connectionId,
details: auditDetails,
});
await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
});
try {
if (rotatingConfiguredApp) {
const updatedAt = new Date();
await db.transaction(async (tx) => {
await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
const current = await tx
.select({ setup: chatEndpoints.setup })
.from(chatEndpoints)
.where(eq(chatEndpoints.id, endpoint.id))
.for("update")
.then((rows) => rows[0] ?? null);
if (!current) throw notFound("Chat endpoint not found");
await tx
.update(chatEndpoints)
.set({
status: "attention",
healthMessage:
"Update the GitHub webhook secret, then reconnect this App",
lastError: null,
setup: {
...current.setup,
step: "provider_setup",
webhookVerifiedAt: null,
runtimeGeneration: runtimeGeneration(current.setup) + 1,
} as InternalSetupState,
updatedAt,
})
.where(eq(chatEndpoints.id, endpoint.id));
await tx
.update(toolConnections)
.set({
status: "disabled",
enabled: false,
healthStatus: "degraded",
healthMessage:
"GitHub webhook secret rotation needs reconnect",
lastError: null,
healthCheckedAt: updatedAt,
updatedAt,
})
.where(eq(toolConnections.id, endpoint.connectionId));
await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
});
await invalidateRuntime(endpoint.id).catch(() => undefined);
} else {
// Replacing a secret while the operator is still creating the GitHub
// App is not a live credential rotation. Keep first-time setup in its
// draft state, clear any ping for the superseded value, and also heal
// endpoints affected by the earlier pre-connect rotation trap.
const updatedAt = new Date();
await db.transaction(async (tx) => {
await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
const current = await tx
.select({ setup: chatEndpoints.setup })
.from(chatEndpoints)
.where(eq(chatEndpoints.id, endpoint.id))
.for("update")
.then((rows) => rows[0] ?? null);
if (!current) throw notFound("Chat endpoint not found");
await tx
.update(chatEndpoints)
.set({
status: "draft",
healthMessage: null,
lastError: null,
setup: {
...current.setup,
step: "provider_setup",
webhookVerifiedAt: null,
},
updatedAt,
})
.where(eq(chatEndpoints.id, endpoint.id));
await tx
.update(toolConnections)
.set({
status: "draft",
enabled: false,
healthStatus: "unchecked",
healthMessage: null,
lastError: null,
healthCheckedAt: null,
updatedAt,
})
.where(eq(toolConnections.id, endpoint.connectionId));
await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
});
}
await options.setupSecretCredentialPersistBarrier?.();
// Make credential refs the final required mutation. Once this returns,
// only the best-effort completion audit remains, so the request cannot
// fail after making the newly generated secret authoritative.
const persistedRefs = await persistCredentials(
endpoint,
{ ...existing, webhookSecret },
credentialLease,
actorUserId,
async (tx) => {
await tx.update(chatEndpoints).set({
setup: sql`${chatEndpoints.setup} || ${JSON.stringify({ credentialRotationId: rotationId, webhookVerifiedAt: null })}::jsonb`,
}).where(eq(chatEndpoints.id, endpoint.id));
},
);
committedWebhookSecretRef =
persistedRefs.find(
(ref) => ref.configPath === "credentials.webhookSecret",
) ?? null;
if (!committedWebhookSecretRef) {
throw new Error(
"GitHub webhook secret was not present after credential persistence",
);
}
} catch (error) {
await writeSetupSecretActivity(db, {
companyId: endpoint.companyId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.setup_secret_rotation_failed",
entityType: "tool_connection",
entityId: endpoint.connectionId,
details: auditDetails,
}).catch((auditError) => {
logger.warn(
{
endpointId: endpoint.id,
error: redactError(auditError),
rotationId,
},
"could not audit failed GitHub setup-secret rotation",
);
});
throw error;
}
await db
.transaction(async (tx) => {
await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
await writeSetupSecretActivity(tx as unknown as Db, {
companyId: endpoint.companyId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.setup_secret_generated",
entityType: "tool_connection",
entityId: endpoint.connectionId,
details: auditDetails,
});
await managementContext?.reauthorize();
await credentialLease.assertOwned(tx);
})
.catch((error) => {
logger.warn(
{
endpointId: endpoint.id,
error: redactError(error),
rotationId,
},
"GitHub setup secret rotated but completion audit failed",
);
});
return { webhookSecret, credentialRotationId: rotationId };
},
{
beforeFinalOwnershipCheck: options.setupSecretFinalOwnershipBarrier,
recoverCommittedResultAfterLeaseLoss: async () => {
const committedRef = committedWebhookSecretRef;
if (!committedRef) return false;
const authoritative = await db
.select({ refs: toolConnections.credentialSecretRefs })
.from(toolConnections)
.innerJoin(
companySecretBindings,
and(
eq(companySecretBindings.companyId, initial.endpoint.companyId),
eq(companySecretBindings.targetType, "tool_connection"),
eq(
companySecretBindings.targetId,
initial.endpoint.connectionId,
),
eq(companySecretBindings.configPath, committedRef.configPath),
eq(companySecretBindings.secretId, committedRef.secretId),
eq(
companySecretBindings.versionSelector,
String(committedRef.versionSelector ?? "latest"),
),
),
)
.where(
and(
eq(toolConnections.companyId, initial.endpoint.companyId),
eq(toolConnections.id, initial.endpoint.connectionId),
),
)
.then((rows) => rows[0] ?? null);
return (
authoritative?.refs.some(
(ref) =>
credentialFingerprint([ref]) ===
credentialFingerprint([committedRef]),
) === true
);
},
},
);
}
return { generateSetupSecret };
}