Files
PaperClipAI/server/src/services/chat-channels/github/app-setup.ts
T
DottaandPaperclip f99e8aa6d5 Keep credential handoffs pending until their requested change commits
Separate saved import, identity, and rotation actions; bind rotation completion atomically to new vault references and fresh verification. Keep signing-key import human-only. Exercise authorized self-targeted policy administration without granting self-approval.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 15:06:38 -05:00

253 lines
16 KiB
TypeScript

import type { ChatEndpointSetupState } from "@paperclipai/shared";
import type { ConnectionManagementContext } from "../../connection-management.js";
import type { VerifiedProviderIdentity } from "../runtime-types.js";
import { createSetupProbes } from "../setup-probes.js";
import type { EndpointRow } from "../runtime-types.js";
import { createChatResourceReconciliation } from "../resource-reconciliation.js";
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
import { chatGitHubRegistrations } from "@paperclipai/db";
import { githubChatWizardService } from "../../chat-github-wizard.js";
import { registerGitHubBotCloudIngress } from "../../chat-github-cloud-ingress.js";
import { githubChatRegistrationService } from "../../chat-github-registration.js";
import { githubAppJwt } from "../../chat-github-client.js";
import {
and,
eq,
inArray,
isNull,
sql,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
chatEndpointResources,
chatEndpoints,
} from "@paperclipai/db";
import type {
ChatEndpoint,
ChatProvider,
ConfigureChatEndpointInput,
ToolCredentialSecretRef,
UpdateChatEndpointInput,
} from "@paperclipai/shared";
import {
HttpError,
conflict,
forbidden,
notFound,
unprocessable,
} from "../../../errors.js";
import {
logActivity,
} from "../../activity-log.js";
import {
listGitHubAppWebhookDeliveries,
requestGitHubAppWebhookRedelivery,
} from "../../chat-github-webhook-config.js";
import type { EndpointRecord, PreparedProviderInventory } from "../runtime-types.js";
type GitHubAppSetupOptions = {
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
normalizedCredentials: (endpoint: EndpointRow, supplied: Record<string, string> | undefined) => Promise<Record<string, string>>;
verifyCredentials: (provider: ChatProvider, credentials: Record<string, string>) => Promise<VerifiedProviderIdentity>;
nativeBotIdentityMatches: (provider: ChatProvider, current: VerifiedProviderIdentity, incoming: VerifiedProviderIdentity) => boolean;
assertNativeBotIdentityAvailable: (endpoint: EndpointRow, identity: VerifiedProviderIdentity) => Promise<void>;
isNativeBotIdentityUniqueViolation: (error: unknown) => boolean;
nativeBotIdentityConflict: (provider: ChatProvider) => HttpError;
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise<ToolCredentialSecretRef[]>;
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
fetchImpl: typeof fetch;
get: (endpointId: string) => Promise<ChatEndpoint>;
getPublicBaseUrl: () => string | null;
getWebhookPublicBaseUrl: () => string | null;
prepareProviderInventory: (endpoint: EndpointRow, credentials: Record<string, string>) => Promise<PreparedProviderInventory>;
reconcileProviderResourceRows: ReturnType<typeof createChatResourceReconciliation>["reconcileProviderResourceRows"];
listResources: ReturnType<typeof createChatResourceReconciliation>["listResources"];
replaceResources: ReturnType<typeof createChatResourceReconciliation>["replaceResources"];
update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise<ChatEndpoint>;
configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null, managementContext?: ConnectionManagementContext) => Promise<ChatEndpoint>;
test: ReturnType<typeof createSetupProbes>["test"];
githubWizardOrigin?: string | null;
handleWebhook: (publicId: string, provider: "github", request: Request) => Promise<Response>;
};
export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
const { endpointRecord, withCredentialMutationLease, normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, persistCredentials, resolveCredentials, fetchImpl, get, getPublicBaseUrl, getWebhookPublicBaseUrl, prepareProviderInventory, reconcileProviderResourceRows, listResources, replaceResources, update, configure, test, handleWebhook } = options;
async function storeGitHubApp(endpointId: string, userId: string, credentials: Record<string, string>, source: "manifest" | "existing_app" = "existing_app", managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
if (managementContext?.actor.type === "agent") throw forbidden("GitHub signing credentials require a human setup handoff");
const initial = await endpointRecord(endpointId);
if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found");
await withCredentialMutationLease(initial.endpoint, async credentialLease => {
const record = await endpointRecord(endpointId);
if (!record || ["archived", "active", "paused"].includes(record.endpoint.status)) throw conflict("Use the reconnect flow for an existing active bot");
const normalized = await normalizedCredentials(record.endpoint, credentials);
const identity = await verifyCredentials("github", normalized);
if (record.endpoint.botExternalId && !nativeBotIdentityMatches("github", record.endpoint, identity)) throw conflict("This App belongs to a different bot. Create a new connection instead.");
await assertNativeBotIdentityAvailable(record.endpoint, identity);
await managementContext?.reauthorize();
// Claim the globally unique App identity before writing credentials.
// A losing concurrent registration must never persist usable secrets.
if (!record.endpoint.botExternalId) {
try {
await db.transaction(async tx => {
await credentialLease.assertOwned(tx);
const [current] = await tx.select({ setup: chatEndpoints.setup }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update");
const [resource] = await tx.select({ id: chatEndpointResources.id }).from(chatEndpointResources).where(eq(chatEndpointResources.endpointId, endpointId)).limit(1);
// A fresh manual App has no registration session. Record its first
// import before vault writes so interrupted credential storage can resume.
const initialImport = !current?.setup.github?.repositorySelectionSaved && !resource;
const claimed = await tx.update(chatEndpoints).set({ status: "attention", botExternalId: identity.botExternalId, botUsername: identity.botUsername, providerAccountId: identity.providerAccountId, providerAccountLabel: identity.providerAccountLabel,
setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || ${JSON.stringify({ initialSetupPending: true, ...(initialImport ? { initialRepositoryImportPending: true } : {}) })}::jsonb)`,
healthMessage: "Complete GitHub App setup", updatedAt: new Date() }).where(and(eq(chatEndpoints.id, endpointId), isNull(chatEndpoints.botExternalId))).returning({ id: chatEndpoints.id });
if (claimed.length !== 1) throw conflict("The bot changed during registration");
});
} catch (error) {
if (isNativeBotIdentityUniqueViolation(error)) throw nativeBotIdentityConflict("github");
throw error;
}
}
await managementContext?.reauthorize();
await persistCredentials(record.endpoint, normalized, credentialLease, userId);
const slug = identity.botUsername?.replace(/\[bot\]$/, "");
if (!slug || !/^[a-z0-9-]+$/.test(slug)) throw unprocessable("GitHub did not identify this App");
await db.transaction(async tx => {
await credentialLease.assertOwned(tx);
const [current] = await tx.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update");
if (!current || ["archived", "active", "paused"].includes(current.status)) throw conflict("Use the reconnect flow for an existing active bot");
await tx.update(chatEndpoints).set({
status: "attention", botExternalId: identity.botExternalId, botUsername: identity.botUsername,
botDisplayName: identity.botLabel, providerAccountId: identity.providerAccountId, providerAccountLabel: identity.providerAccountLabel,
setup: { ...current.setup, github: { ...current.setup.github, appOwnerType: identity.githubOwnerType, appOwnerLogin: identity.githubOwnerLogin, stage: "install", appSlug: slug, installationUrl: `https://github.com/apps/${slug}/installations/new`, registrationStatus: "completed" } },
healthMessage: "Install the App on GitHub to continue", updatedAt: new Date(),
}).where(eq(chatEndpoints.id, endpointId));
if (source === "existing_app") {
// Explicit recovery uses the existing-App identity path. Only drop the
// manifest requirement after the same App's credentials are vaulted;
// leave registration incomplete so advance repairs and verifies delivery.
await tx.update(chatGitHubRegistrations).set({
handoff: sql`${chatGitHubRegistrations.handoff} - 'manifestClaimId'`,
}).where(and(
eq(chatGitHubRegistrations.companyId, record.endpoint.companyId),
eq(chatGitHubRegistrations.endpointId, endpointId),
eq(chatGitHubRegistrations.userId, userId),
inArray(chatGitHubRegistrations.status, ["pending", "exchanging", "failed"]),
sql`${chatGitHubRegistrations.handoff} ? 'manifestClaimId'`,
));
}
await logActivity(tx as unknown as Db, { companyId: record.endpoint.companyId, ...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }), action: "chat_github.app_connected", entityType: "tool_connection", entityId: record.endpoint.connectionId, details: { endpointId, appId: identity.botExternalId } });
});
});
// Manifest registration can send its ping before the credential exchange
// returns. Request a provider-signed replay after vaulting the secret.
const stored = await endpointRecord(endpointId);
const credentialsNow = await resolveCredentials(stored!.endpoint);
try {
const appToken = githubAppJwt(credentialsNow.appId, credentialsNow.privateKey);
const history = await listGitHubAppWebhookDeliveries({ fetch: fetchImpl, appToken });
const ping = history.deliveries.find(delivery => delivery.event === "ping");
if (ping) await requestGitHubAppWebhookRedelivery({ fetch: fetchImpl, appToken, deliveryId: ping.id });
} catch {
// Registration succeeded. The verify screen exposes signed-delivery
// recovery rather than reporting credentials as failed or exchanging again.
}
return get(endpointId);
}
const githubRegistration = githubChatRegistrationService(db, {
publicOrigin: () => getPublicBaseUrl(), webhookOrigin: () => getWebhookPublicBaseUrl(), fetch: fetchImpl,
storeApp: async (endpointId, userId, app, managementContext) => { await storeGitHubApp(endpointId, userId, { appId: app.appId, privateKey: app.privateKey, webhookSecret: app.webhookSecret, ...(app.clientId ? { clientId: app.clientId } : {}), ...(app.clientSecret ? { clientSecret: app.clientSecret } : {}) }, "manifest", managementContext); },
});
async function refreshGitHubRepositories(endpointId: string, userId: string, managementContext?: ConnectionManagementContext) {
await managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found");
await withCredentialMutationLease(initial.endpoint, async lease => {
const record = await endpointRecord(endpointId);
if (!record) throw notFound("GitHub bot not found");
const credentials = await resolveCredentials(record.endpoint);
const prepared = await prepareProviderInventory(record.endpoint, credentials);
await managementContext?.reauthorize();
if (prepared.credentials.installationId !== credentials.installationId) await persistCredentials(record.endpoint, prepared.credentials, lease, userId);
if (prepared.inventory) await reconcileProviderResourceRows(record.endpoint, prepared.inventory, lease);
await lease.assertOwned();
await db.update(chatEndpoints).set({ setup: { ...record.endpoint.setup, github: { ...record.endpoint.setup.github, stage: "repositories", managementUrl: prepared.managementUrl } }, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId));
});
return listResources(endpointId);
}
const githubWizard = githubChatWizardService(db, {
origin: () => getPublicBaseUrl() ?? options.githubWizardOrigin ?? null, fetch: fetchImpl, startDirect: githubRegistration.start, completeDirect: githubRegistration.complete, resumeDirect: githubRegistration.resumeRegistration,
storeApp: (id, userId, credentials, managementContext) => storeGitHubApp(id, userId, credentials, "manifest", managementContext),
storeCredentials: async (id, userId, additional) => {
const record = await endpointRecord(id);
if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found");
await withCredentialMutationLease(record.endpoint, async lease => {
const current = await endpointRecord(id);
if (!current || ["archived", "revoked", "paused"].includes(current.endpoint.status)) throw conflict("GitHub App is unavailable");
const credentials = await resolveCredentials(current.endpoint);
await persistCredentials(current.endpoint, { ...credentials, ...additional }, lease, userId);
});
},
refreshRepositories: refreshGitHubRepositories, resources: listResources, replaceResources,
configure: async (id, userId, context) => {
await update(id, { allowGroupChats: true }, userId, context);
return configure(id, { action: "configure" }, userId, context);
},
finish: (id, userId, context) => test(id, { optionalSlackTestForUser: userId }, context),
});
const unregisterGitHubCloudIngress = registerGitHubBotCloudIngress(
db,
async (endpoint, request) => {
const record = await endpointRecord(endpoint.id);
if (
!record ||
record.endpoint.companyId !== endpoint.companyId ||
record.endpoint.connectionId !== endpoint.connectionId ||
record.endpoint.assignedAgentId !== endpoint.assignedAgentId ||
record.endpoint.setup.github?.cloudRegistrationId !==
endpoint.setup.github?.cloudRegistrationId
)
throw forbidden("GitHub Cloud event binding changed");
// A manifest ping may arrive before its webhook verifier reaches the vault.
// Keep the sealed delivery queued until the stack can authenticate it.
if (!record.credentialSecretRefs.some((ref) => ref.configPath === "credentials.webhookSecret"))
throw conflict("GitHub webhook credentials are not ready");
// Preserve the exact provider payload and signature. Authentication, lifecycle
// checks and durable admission all run in the stack's canonical ingress.
const response = await handleWebhook(
record.endpoint.publicId,
"github",
request,
);
if ([400, 401, 403, 404, 413, 422].includes(response.status)) return; // Discard hostile transport without retrying it indefinitely.
if (!response.ok)
throw conflict("GitHub Cloud ingress could not be durably accepted");
},
);
const saveGitHubSetupProgress = async (endpointId: string, stage: NonNullable<ChatEndpointSetupState["github"]>["stage"]) => {
const record = await endpointRecord(endpointId);
if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found");
await db.update(chatEndpoints).set({ setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || ${JSON.stringify({ stage })}::jsonb)`, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId));
return get(endpointId);
};
return { storeGitHubApp, githubRegistration, refreshGitHubRepositories, githubWizard, unregisterGitHubCloudIngress, saveGitHubSetupProgress };
}