Files
PaperClipAI/server/src/services/chat-github-wizard.ts
T

1418 lines
48 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import { and, desc, eq, sql } from "drizzle-orm";
import {
chatEndpoints,
chatGitHubRegistrations,
chatExternalPrincipals,
chatIdentityLinks,
companies,
companyMemberships,
toolConnections,
type Db,
} from "@paperclipai/db";
import type {
GitHubAppRegistrationInput,
GitHubAppWizardState,
GitHubAppCloudState,
} from "@paperclipai/shared";
import { badRequest, conflict, forbidden, notFound } from "../errors.js";
import { logActivity } from "./activity-log.js";
import {
githubBotCredentials,
githubBotRequest,
} from "./chat-github-client.js";
import {
resyncGitHubAppWebhook,
listGitHubAppWebhookDeliveries,
requestGitHubAppWebhookRedelivery,
} from "./chat-github-webhook-config.js";
import { githubChatManagementService } from "./chat-github-management.js";
import {
createPaperclipCloudConnector,
paperclipCloudConnectorConfigFromEnv,
type PaperclipCloudConnector,
} from "./paperclip-cloud-connector.js";
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
const nonce = () => randomBytes(32).toString("base64url");
type Endpoint = typeof chatEndpoints.$inferSelect;
type Resource = { id: string; enabled: boolean; availability: string };
/** Manager-only orchestration. Provider callbacks resume this same durable draft. */
export function githubChatWizardService(
db: Db,
options: {
origin: () => string | null;
fetch?: typeof fetch;
connector?: () => PaperclipCloudConnector | null;
startDirect: (
endpointId: string,
userId: string,
input: GitHubAppRegistrationInput,
) => Promise<{
registrationUrl: string;
manifest: Record<string, unknown>;
expiresAt: string;
}>;
resumeDirect?: (
endpointId: string,
userId: string,
) => Promise<{
registrationUrl: string;
manifest: Record<string, unknown>;
expiresAt: string;
}>;
completeDirect?: (state: string, code: string) => Promise<string>;
storeApp: (
endpointId: string,
userId: string,
credentials: Record<string, string>,
) => Promise<unknown>;
storeCredentials: (
endpointId: string,
userId: string,
credentials: Record<string, string>,
) => Promise<void>;
refreshRepositories: (
endpointId: string,
userId: string,
) => Promise<Resource[]>;
resources: (endpointId: string) => Promise<Resource[]>;
replaceResources: (
endpointId: string,
updates: Array<{ id: string; enabled: boolean }>,
userId: string,
options?: { initialGitHubImport?: boolean },
) => Promise<unknown>;
configure: (endpointId: string, userId: string) => Promise<unknown>;
finish: (endpointId: string, userId: string) => Promise<unknown>;
},
) {
const fetchImpl = options.fetch ?? fetch;
const management = githubChatManagementService(db, fetchImpl);
const connector =
options.connector ??
(() => {
const config = paperclipCloudConnectorConfigFromEnv();
return config
? createPaperclipCloudConnector({ config, request: fetchImpl })
: null;
});
const pending = new Map<string, Promise<GitHubAppWizardState>>();
async function endpoint(id: string, userId: string): Promise<Endpoint> {
const [bot] = await db
.select()
.from(chatEndpoints)
.where(
and(eq(chatEndpoints.id, id), eq(chatEndpoints.provider, "github")),
);
if (!bot || bot.status === "archived")
throw notFound("GitHub bot not found");
const [member] = await db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, bot.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
);
if (!member || member.membershipRole === "viewer")
throw forbidden("An active company manager is required");
return bot;
}
async function registration(bot: Endpoint) {
const [session] = await db
.select()
.from(chatGitHubRegistrations)
.where(
and(
eq(chatGitHubRegistrations.companyId, bot.companyId),
eq(chatGitHubRegistrations.endpointId, bot.id),
),
)
.orderBy(desc(chatGitHubRegistrations.createdAt))
.limit(1);
return session;
}
async function setup(
bot: Endpoint,
values: Partial<NonNullable<Endpoint["setup"]["github"]>>,
) {
await db
.update(chatEndpoints)
.set({
setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github','{}'::jsonb) || ${JSON.stringify(values)}::jsonb)`,
updatedAt: new Date(),
})
.where(
and(
eq(chatEndpoints.id, bot.id),
eq(chatEndpoints.companyId, bot.companyId),
),
);
}
async function resume(bot: Endpoint) {
const [company] = await db
.select({ prefix: companies.issuePrefix })
.from(companies)
.where(eq(companies.id, bot.companyId));
return `${options.origin()}/${company!.prefix}/apps/chat/connect?provider=github&purpose=chat&resume=${bot.id}`;
}
function origin() {
const value = options.origin();
if (!value)
throw badRequest(
"Configure this instance's browser address before connecting GitHub",
);
const url = new URL(value);
if (
url.username ||
url.password ||
url.search ||
url.hash ||
url.pathname !== "/" ||
(url.protocol !== "https:" &&
!(
url.protocol === "http:" &&
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
))
)
throw badRequest("Use an enrolled HTTPS or local browser address");
return url.origin;
}
async function start(
id: string,
userId: string,
input: GitHubAppRegistrationInput,
) {
const bot = await endpoint(id, userId);
if (bot.botExternalId)
throw conflict(
"This draft already has an App. Continue its installation or recover its credentials.",
);
const prior = await registration(bot);
if (prior && ["pending", "exchanging", "failed"].includes(prior.status)) {
if (prior.userId !== userId)
throw conflict("The configuring member must resume this registration");
if (prior.status !== "pending" || prior.expiresAt <= new Date())
throw conflict(
"Recover the existing App from GitHub settings; its registration cannot safely be repeated.",
);
if (prior.handoff) return advance(id, userId);
// The direct flow's hashed state cannot be reconstructed. Do not create a duplicate App.
throw conflict(
"An App registration is already pending. Complete its open GitHub page, or use existing-App recovery.",
);
}
await saveDraft(id, userId, input);
// Only a fresh registration may import GitHub's initial selection. Old
// drafts without this marker retain even an entirely disabled selection.
if (
!bot.setup.github?.repositorySelectionSaved &&
!(await options.resources(id)).length
)
await setup(bot, { initialRepositoryImportPending: true });
const cloud = connector();
if (!cloud) {
if (origin().startsWith("https://"))
return {
endpointId: id,
state: "create",
registration: await options.startDirect(id, userId, input),
} satisfies GitHubAppWizardState;
return {
endpointId: id,
state: "enrollment",
message:
"Use Paperclip Cloud to receive GitHub events for this local instance.",
} satisfies GitHubAppWizardState;
}
if (!(await cloud.githubAppsAvailable()))
throw conflict(
"GitHub App setup is not enabled on your Paperclip Cloud connector yet.",
);
const trustedOrigin = origin();
const session = await db.transaction(async (tx) => {
const [locked] = await tx
.select()
.from(chatEndpoints)
.where(eq(chatEndpoints.id, id))
.for("update");
if (!locked || locked.botExternalId || locked.status === "archived")
throw conflict("This draft changed. Resume its existing App.");
const [prior] = await tx
.select()
.from(chatGitHubRegistrations)
.where(eq(chatGitHubRegistrations.endpointId, id))
.orderBy(desc(chatGitHubRegistrations.createdAt))
.limit(1);
if (prior) {
if (
prior.userId !== userId ||
!prior.handoff?.cloudId ||
prior.status !== "pending" ||
prior.expiresAt <= new Date()
)
throw conflict(
"Resume or recover the App already registered for this draft.",
);
return prior;
}
const returnState = nonce();
const [created] = await tx
.insert(chatGitHubRegistrations)
.values({
companyId: bot.companyId,
endpointId: id,
userId,
stateHash: hash(returnState),
trustedOrigin,
ownerType: input.ownerType,
ownerLogin: input.ownerLogin,
appName: input.name,
expiresAt: new Date(Date.now() + 30 * 60_000),
handoff: { cloudId: nonce(), returnState, redemptionId: nonce() },
})
.returning();
await logActivity(tx as unknown as Db, {
companyId: bot.companyId,
actorType: "user",
actorId: userId,
action: "chat_github.registration_started",
entityType: "tool_connection",
entityId: bot.connectionId,
details: {
endpointId: id,
registrationId: created!.id,
ownerType: input.ownerType,
transport: "paperclip_cloud",
},
});
return created!;
});
await setup(bot, {
stage: "connect",
ownerType: session.ownerType,
ownerLogin: session.ownerLogin ?? undefined,
appName: session.appName ?? undefined,
cloudRegistrationId: session.handoff!.cloudId,
registrationStatus: "pending",
});
const state = await cloud.githubApp({
subject: userId,
companyId: bot.companyId,
binding: cloudStart(session),
});
return {
endpointId: id,
state: "create",
registration: cloudManifest(session, state),
} satisfies GitHubAppWizardState;
}
function cloudStart(
session: typeof chatGitHubRegistrations.$inferSelect,
) {
return {
action: "start",
id: session.handoff!.cloudId,
returnUri: `${session.trustedOrigin}/api/chat-github/cloud/callback`,
returnState: session.handoff!.returnState,
};
}
function cloudManifest(
session: typeof chatGitHubRegistrations.$inferSelect,
route: GitHubAppCloudState,
) {
if (
!route.callbackUrls?.manifest ||
!route.callbackUrls.install ||
!route.callbackUrls.oauth ||
!route.webhookUrl
)
throw conflict(
"This Cloud connector needs the gateway update before setup can continue.",
);
const url = new URL(
session.ownerType === "organization"
? `https://github.com/organizations/${encodeURIComponent(session.ownerLogin!)}/settings/apps/new`
: "https://github.com/settings/apps/new",
);
url.searchParams.set("state", session.handoff!.returnState);
return {
registrationUrl: url.toString(),
expiresAt: session.expiresAt.toISOString(),
manifest: {
name: session.appName,
url: session.trustedOrigin.startsWith("https:")
? session.trustedOrigin
: new URL(route.webhookUrl).origin,
public: false,
hook_attributes: { url: route.webhookUrl, active: true },
redirect_url: route.callbackUrls.manifest,
setup_url: route.callbackUrls.install,
setup_on_update: true,
callback_urls: [route.callbackUrls.oauth],
default_permissions: {
contents: "read",
issues: "write",
metadata: "read",
pull_requests: "write",
checks: "write",
},
default_events: [
"issue_comment",
"pull_request_review_comment",
"pull_request",
],
},
};
}
async function exchangeCloudManifest(
session: typeof chatGitHubRegistrations.$inferSelect,
claimId: string,
) {
const cloud = connector();
if (!cloud || !session.handoff)
throw conflict("Restore Paperclip Cloud enrollment");
const material = await cloud.claimGitHubApp({
subject: session.userId,
companyId: session.companyId,
claimId,
redemptionId: session.handoff.redemptionId,
});
if (
material.kind !== "gateway_callback" ||
material.registrationId !== session.handoff.cloudId ||
material.callbackKind !== "manifest" ||
typeof material.state !== "string" ||
hash(material.state) !== session.stateHash ||
typeof material.code !== "string" ||
!/^[A-Za-z0-9_-]{1,256}$/.test(material.code)
)
throw forbidden("GitHub manifest return did not match this draft");
// Claim the single-use operation in the stack database before calling GitHub.
const [claimed] = await db
.update(chatGitHubRegistrations)
.set({ status: "exchanging", consumedAt: new Date() })
.where(
and(
eq(chatGitHubRegistrations.id, session.id),
eq(chatGitHubRegistrations.status, "pending"),
sql`${chatGitHubRegistrations.expiresAt} > now()`,
),
)
.returning();
if (!claimed) return;
try {
await endpoint(session.endpointId, session.userId);
const app = await githubBotRequest<{
id: number;
pem: string;
webhook_secret: string;
client_id: string;
client_secret: string;
slug: string;
owner: { type: string; login: string };
}>(
fetchImpl,
null,
`/app-manifests/${encodeURIComponent(material.code)}/conversions`,
{ method: "POST" },
);
if (
!Number.isSafeInteger(app.id) ||
app.id <= 0 ||
!app.pem ||
!app.webhook_secret ||
!app.client_id ||
!app.client_secret ||
!/^[a-z0-9-]+$/.test(app.slug ?? "")
)
throw conflict(
"GitHub returned incomplete App credentials. Recover the App already created.",
);
if (
app.owner?.type !==
(session.ownerType === "organization" ? "Organization" : "User") ||
(session.ownerLogin &&
app.owner.login?.toLowerCase() !== session.ownerLogin.toLowerCase())
)
throw forbidden("GitHub returned an App owned by a different account");
await options.storeApp(session.endpointId, session.userId, {
appId: String(app.id),
privateKey: app.pem,
webhookSecret: app.webhook_secret,
clientId: app.client_id,
clientSecret: app.client_secret,
slug: app.slug,
});
await db
.update(chatGitHubRegistrations)
.set({
status: "completed",
handoff: sql`${chatGitHubRegistrations.handoff} - 'manifestClaimId'`,
})
.where(eq(chatGitHubRegistrations.id, session.id));
} catch (error) {
await db
.update(chatGitHubRegistrations)
.set({ status: "failed" })
.where(eq(chatGitHubRegistrations.id, session.id));
throw error;
}
}
async function saveDraft(
id: string,
userId: string,
input: GitHubAppRegistrationInput,
) {
const bot = await endpoint(id, userId);
await db.transaction(async (tx) => {
await tx
.select()
.from(chatEndpoints)
.where(eq(chatEndpoints.id, id))
.for("update");
const [session] = await tx
.select()
.from(chatGitHubRegistrations)
.where(eq(chatGitHubRegistrations.endpointId, id))
.limit(1);
if (bot.botExternalId || session)
throw conflict(
"Resume the existing App registration before changing its account.",
);
await tx
.update(chatEndpoints)
.set({
setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github','{}'::jsonb) || ${JSON.stringify({ ownerType: input.ownerType, ownerLogin: input.ownerType === "organization" ? input.ownerLogin : null, appName: input.name })}::jsonb)`,
updatedAt: new Date(),
})
.where(eq(chatEndpoints.id, id));
await logActivity(tx as unknown as Db, {
companyId: bot.companyId,
actorType: "user",
actorId: userId,
action: "chat_github.draft_saved",
entityType: "tool_connection",
entityId: bot.connectionId,
details: { endpointId: id },
});
});
return { saved: true as const };
}
async function repairCloud(id: string, userId: string) {
const bot = await endpoint(id, userId),
session = await registration(bot),
cloud = connector();
if (!session?.handoff?.cloudId) return;
if (session.userId !== userId)
throw forbidden("The configuring member must recover this registration");
if (!cloud)
throw conflict(
"Restore Paperclip Cloud enrollment before reconnecting this App",
);
const binding = { subject: userId, companyId: bot.companyId };
const state = await cloud.githubApp({
...binding,
binding: cloudStart(session),
});
const { credentials, appJwt } = await githubBotCredentials(
db,
bot.companyId,
id,
);
if (!credentials.webhookSecret)
throw conflict("Recover this App's webhook secret before continuing");
if (!state.webhookUrl)
throw conflict("Restore this App's Cloud gateway before continuing");
await logActivity(db, {
companyId: bot.companyId,
actorType: "user",
actorId: userId,
action: "chat_github.webhook_repair_started",
entityType: "tool_connection",
entityId: bot.connectionId,
details: { endpointId: id, appId: credentials.appId },
});
// The App JWT and webhook secret remain entirely in the instance.
await resyncGitHubAppWebhook({
fetch: fetchImpl,
appToken: appJwt,
webhookUrl: state.webhookUrl,
webhookSecret: credentials.webhookSecret,
});
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`jsonb_set(${chatGitHubRegistrations.handoff}, '{webhookSecretHash}', ${JSON.stringify(hash(credentials.webhookSecret))}::jsonb)`,
status: "completed",
})
.where(eq(chatGitHubRegistrations.id, session.id));
await setup(bot, {
cloudRegistrationId: session.handoff.cloudId,
registrationStatus: "completed",
});
await db
.update(chatEndpoints)
.set({
setup: sql`jsonb_set(${chatEndpoints.setup}, '{webhookVerifiedAt}', 'null'::jsonb)`,
updatedAt: new Date(),
})
.where(eq(chatEndpoints.id, id));
try {
const history = await listGitHubAppWebhookDeliveries({
fetch: fetchImpl,
appToken: appJwt,
});
const ping = history.deliveries.find(
(delivery) => delivery.event === "ping",
);
if (ping)
await requestGitHubAppWebhookRedelivery({
fetch: fetchImpl,
appToken: appJwt,
deliveryId: ping.id,
});
} catch {
/* Keep verification pending until a fresh provider-signed delivery arrives. */
}
await logActivity(db, {
companyId: bot.companyId,
actorType: "user",
actorId: userId,
action: "chat_github.webhook_repaired",
entityType: "tool_connection",
entityId: bot.connectionId,
details: { endpointId: id, appId: credentials.appId },
});
}
async function advance(
id: string,
userId: string,
): Promise<GitHubAppWizardState> {
// Multiple open tabs for this manager share one operation, not concurrent vault writes.
const key = `${id}:${userId}`;
if (pending.has(key)) return pending.get(key)!;
const work = advanceOnce(id, userId);
pending.set(key, work);
try {
return await work;
} finally {
if (pending.get(key) === work) pending.delete(key);
}
}
async function advanceOnce(
id: string,
userId: string,
): Promise<GitHubAppWizardState> {
let bot = await endpoint(id, userId);
if (["paused", "revoked"].includes(bot.status))
return {
endpointId: id,
state: "recovery",
message: "Reconnect this App from connection settings.",
};
const session = await registration(bot),
cloud = connector();
if (session?.handoff?.cloudId) {
if (session.userId !== userId)
throw forbidden(
"The configuring member must complete this registration",
);
if (!cloud)
return {
endpointId: id,
state: "enrollment",
message:
"Restore this instance's Paperclip Cloud enrollment to resume.",
};
const [connection] = await db
.select({ refs: toolConnections.credentialSecretRefs })
.from(toolConnections)
.where(
and(
eq(toolConnections.id, bot.connectionId),
eq(toolConnections.companyId, bot.companyId),
),
);
const requiredCredentialKeys = session.handoff.manifestClaimId
? ["appId", "privateKey", "webhookSecret", "clientId", "clientSecret"]
: ["appId", "privateKey", "webhookSecret"];
let hasAppCredentials =
!!bot.botExternalId &&
requiredCredentialKeys.every((key) =>
connection?.refs.some(
(ref) => ref.configPath === `credentials.${key}`,
),
);
const binding = { subject: userId, companyId: bot.companyId };
const route = await cloud.githubApp({
...binding,
binding: cloudStart(session),
});
let exchangedThisTurn = false;
if (
session.status === "pending" &&
session.handoff.manifestClaimId &&
!hasAppCredentials
) {
await exchangeCloudManifest(session, session.handoff.manifestClaimId);
bot = await endpoint(id, userId);
const [savedConnection] = await db
.select({ refs: toolConnections.credentialSecretRefs })
.from(toolConnections)
.where(
and(
eq(toolConnections.id, bot.connectionId),
eq(toolConnections.companyId, bot.companyId),
),
);
hasAppCredentials = requiredCredentialKeys.every(
(key) =>
savedConnection?.refs.some(
(ref) => ref.configPath === `credentials.${key}`,
),
);
exchangedThisTurn = true;
}
if (!bot.botExternalId) {
const current = await registration(bot);
if (current?.status !== "pending" || session.expiresAt <= new Date())
return {
endpointId: id,
state: "recovery",
message:
"Recover the App already created on GitHub. An interrupted exchange cannot safely be repeated.",
};
return {
endpointId: id,
state: "create",
registration: cloudManifest(session, route),
};
}
if (!hasAppCredentials)
return {
endpointId: id,
state: "recovery",
message: "Recover this App's saved credentials before continuing.",
};
const { credentials: stored } = await githubBotCredentials(
db,
bot.companyId,
id,
);
if (
(!exchangedThisTurn && session.status !== "completed") ||
(session.handoff.webhookSecretHash &&
session.handoff.webhookSecretHash !==
hash(stored.webhookSecret ?? ""))
) {
await repairCloud(id, userId);
bot = await endpoint(id, userId);
}
if (session.status !== "completed")
await db
.update(chatGitHubRegistrations)
.set({ status: "completed" })
.where(eq(chatGitHubRegistrations.id, session.id));
if (!session.handoff.webhookSecretHash)
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`jsonb_set(${chatGitHubRegistrations.handoff}, '{webhookSecretHash}', ${JSON.stringify(hash(stored.webhookSecret ?? ""))}::jsonb)`,
})
.where(eq(chatGitHubRegistrations.id, session.id));
await setup(bot, {
cloudRegistrationId: session.handoff.cloudId,
ownerType: session.ownerType,
ownerLogin: session.ownerLogin ?? undefined,
appName: session.appName ?? undefined,
registrationStatus: "completed",
});
}
bot = await endpoint(id, userId);
if (!bot.botExternalId) {
if (session && !session.handoff?.cloudId) {
if (
session.status === "pending" &&
session.expiresAt > new Date() &&
session.handoff?.returnState &&
options.resumeDirect
)
return {
endpointId: id,
state: "create",
registration: await options.resumeDirect(id, userId),
};
return {
endpointId: id,
state: "recovery",
message:
"Resume the open GitHub registration page or recover the existing App from GitHub settings.",
};
}
return { endpointId: id, state: "create" };
}
if (bot.setup.github?.initialSetupPending) {
try {
const stored = await githubBotCredentials(db, bot.companyId, id);
if (!stored.credentials.webhookSecret)
throw conflict("The App webhook secret is missing");
} catch {
return {
endpointId: id,
state: "recovery",
message:
"This App's credentials were not completely saved. Recover its existing credentials before continuing.",
};
}
}
if (
!bot.providerAccountId ||
bot.setup.github?.stage === "install" ||
bot.setup.github?.initialSetupPending
) {
try {
await options.refreshRepositories(id, userId);
} catch (error) {
return {
endpointId: id,
state: "install",
installationUrl: bot.setup.github?.installationUrl,
message:
error instanceof Error
? error.message
: "Waiting for installation or administrator approval on GitHub.",
};
}
}
bot = await endpoint(id, userId);
const saved = await management.configuration(id, userId);
if (!bot.setup.github?.initialRepositoriesImported) {
const resources = await options.resources(id);
// Fresh manifest and manual Apps import GitHub's initial choices. Saved
// selections and configuration still belong to the user on recovery.
const initializeApp =
!!session || bot.setup.github?.initialSetupPending === true;
if (
initializeApp &&
bot.status !== "active" &&
saved.revision === 0 &&
bot.setup.github?.initialRepositoryImportPending === true &&
!bot.setup.github?.repositorySelectionSaved &&
!resources.some((resource) => resource.enabled)
) {
await options.replaceResources(
id,
resources
.filter((resource) => resource.availability === "available")
.map((resource) => ({ id: resource.id, enabled: true })),
userId,
{ initialGitHubImport: true },
);
}
if (initializeApp && saved.revision === 0 && bot.status !== "active")
await management.saveConfiguration(
id,
{
expectedRevision: 0,
configuration: {
...saved.configuration,
toolsEnabled: true,
defaults: {
...saved.configuration.defaults,
invocation: "mentions_only",
events: [],
},
},
},
userId,
);
await setup(bot, {
initialRepositoriesImported: true,
initialSetupPending: false,
});
}
if (bot.status !== "active" && bot.status !== "verifying")
await options.configure(id, userId);
bot = await endpoint(id, userId);
let linked = await linkedIdentity(bot, userId);
if (!linked) {
const [prior] = await db
.select({
githubUserId: chatExternalPrincipals.externalId,
login: chatExternalPrincipals.handle,
})
.from(chatIdentityLinks)
.innerJoin(
chatExternalPrincipals,
eq(chatExternalPrincipals.id, chatIdentityLinks.principalId),
)
.where(
and(
eq(chatIdentityLinks.companyId, bot.companyId),
eq(chatIdentityLinks.paperclipUserId, userId),
eq(chatIdentityLinks.status, "linked"),
eq(chatExternalPrincipals.provider, "github"),
),
)
.limit(1);
if (prior?.login) {
await management.linkObservedIdentity(id, userId, {
githubUserId: prior.githubUserId,
login: prior.login,
avatarUrl: null,
});
linked = true;
}
}
if (!linked) {
const { credentials } = await githubBotCredentials(db, bot.companyId, id);
return {
endpointId: id,
state: "identity",
identityMethod:
session && credentials.clientId && credentials.clientSecret
? "dedicated_app"
: "existing_connection",
identity:
session?.handoff?.identity &&
Date.parse(session.handoff.identity.expiresAt) > Date.now()
? session.handoff.identity
: undefined,
};
}
const verification = await management.verification(id);
const connectionChecks = verification.checks.filter(
(check) => !["runtime", "isolation"].includes(check.key),
);
const runtimeChecks = verification.checks.filter((check) =>
["runtime", "isolation"].includes(check.key),
);
const connectionReady = connectionChecks.every((check) => check.ok);
if (connectionReady && bot.status !== "active")
await options.finish(id, userId);
return {
endpointId: id,
state: connectionReady ? "connected" : "verify",
identityLinked: true,
verification: { ready: connectionReady, checks: connectionChecks },
runtimeChecks,
};
}
async function linkedIdentity(bot: Endpoint, userId: string) {
const [link] = await db
.select({ id: chatIdentityLinks.id })
.from(chatIdentityLinks)
.where(
and(
eq(chatIdentityLinks.companyId, bot.companyId),
eq(chatIdentityLinks.endpointId, bot.id),
eq(chatIdentityLinks.paperclipUserId, userId),
eq(chatIdentityLinks.status, "linked"),
),
)
.limit(1);
return !!link;
}
async function withIdentityLease<T>(
sessionId: string,
work: (assertOwned: () => Promise<void>) => Promise<T>,
): Promise<T> {
const leaseId = nonce();
const leaseUntil = () => new Date(Date.now() + 120_000).toISOString();
const [claimed] = await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`coalesce(${chatGitHubRegistrations.handoff}, '{"cloudId":"","returnState":"","redemptionId":""}'::jsonb) || ${JSON.stringify({ identityLeaseId: leaseId, identityLeaseExpiresAt: leaseUntil() })}::jsonb`,
})
.where(
and(
eq(chatGitHubRegistrations.id, sessionId),
sql`(${chatGitHubRegistrations.handoff}->>'identityLeaseExpiresAt' is null or ${chatGitHubRegistrations.handoff}->>'identityLeaseExpiresAt' <= ${new Date().toISOString()})`,
),
)
.returning({ id: chatGitHubRegistrations.id });
if (!claimed)
throw conflict(
"Another account-linking attempt is in progress. Try again shortly.",
);
let lost = false;
const assertOwned = async () => {
const [owned] = await db
.select({ id: chatGitHubRegistrations.id })
.from(chatGitHubRegistrations)
.where(
and(
eq(chatGitHubRegistrations.id, sessionId),
sql`${chatGitHubRegistrations.handoff}->>'identityLeaseId' = ${leaseId}`,
sql`${chatGitHubRegistrations.handoff}->>'identityLeaseExpiresAt' > ${new Date().toISOString()}`,
),
);
if (lost || !owned)
throw conflict("Account-linking changed. Connect your account again.");
};
const renewal = setInterval(() => {
void db
.update(chatGitHubRegistrations)
.set({
handoff: sql`jsonb_set(${chatGitHubRegistrations.handoff}, '{identityLeaseExpiresAt}', ${JSON.stringify(leaseUntil())}::jsonb)`,
})
.where(
and(
eq(chatGitHubRegistrations.id, sessionId),
sql`${chatGitHubRegistrations.handoff}->>'identityLeaseId' = ${leaseId}`,
),
)
.returning({ id: chatGitHubRegistrations.id })
.then((rows) => {
if (!rows.length) lost = true;
})
.catch(() => {
lost = true;
});
}, 30_000);
renewal.unref();
try {
return await work(assertOwned);
} finally {
clearInterval(renewal);
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`${chatGitHubRegistrations.handoff} - 'identityLeaseId' - 'identityLeaseExpiresAt'`,
})
.where(
and(
eq(chatGitHubRegistrations.id, sessionId),
sql`${chatGitHubRegistrations.handoff}->>'identityLeaseId' = ${leaseId}`,
),
);
}
}
async function startIdentity(id: string, userId: string) {
const bot = await endpoint(id, userId),
session = await registration(bot);
if (!session || session.userId !== userId)
throw forbidden("The configuring member must link their GitHub account");
return withIdentityLease(session.id, (assertOwned) =>
startIdentityLocked(id, userId, assertOwned),
);
}
async function startIdentityLocked(
id: string,
userId: string,
assertOwned: () => Promise<void>,
) {
const bot = await endpoint(id, userId),
session = await registration(bot);
if (!session || session.userId !== userId)
throw forbidden("The configuring member must link their GitHub account");
const credentials = await githubBotCredentials(db, bot.companyId, id);
if (
!credentials.credentials.clientId ||
!credentials.credentials.clientSecret
)
throw conflict(
"This existing App needs a personal GitHub connection to verify your account.",
);
const verifier = nonce(),
state = nonce();
await assertOwned();
await options.storeCredentials(id, userId, {
identityCodeVerifier: verifier,
});
await assertOwned();
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`(${chatGitHubRegistrations.handoff} - 'identity') || ${JSON.stringify({ identityStateHash: hash(state), identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), identityRedemptionId: nonce() })}::jsonb`,
})
.where(eq(chatGitHubRegistrations.id, session.id));
const challenge = createHash("sha256").update(verifier).digest("base64url");
if (session.handoff?.cloudId) {
const cloud = connector();
if (!cloud) throw conflict("Restore Paperclip Cloud enrollment");
const result = await cloud.githubApp({
subject: userId,
companyId: bot.companyId,
binding: {
action: "callback",
id: session.handoff.cloudId,
stateHash: hash(state),
},
});
if (!result.callbackUrls?.oauth)
throw conflict("GitHub account authorization is unavailable");
const url = new URL("https://github.com/login/oauth/authorize");
url.searchParams.set("client_id", credentials.credentials.clientId);
url.searchParams.set("redirect_uri", result.callbackUrls.oauth);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
return { authorizationUrl: url.toString() };
}
const url = new URL("https://github.com/login/oauth/authorize");
url.searchParams.set("client_id", credentials.credentials.clientId);
url.searchParams.set(
"redirect_uri",
`${origin()}/api/chat-github/identity/callback`,
);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
return { authorizationUrl: url.toString() };
}
async function completeIdentity(
session: typeof chatGitHubRegistrations.$inferSelect,
code: string,
redirectUri: string,
) {
return withIdentityLease(session.id, async (assertOwned) => {
const [current] = await db
.select()
.from(chatGitHubRegistrations)
.where(eq(chatGitHubRegistrations.id, session.id));
if (
!current ||
current.handoff?.identityStateHash !==
session.handoff?.identityStateHash
)
return resume(await endpoint(session.endpointId, session.userId));
return completeIdentityLocked(current, code, redirectUri, assertOwned);
});
}
async function completeIdentityLocked(
session: typeof chatGitHubRegistrations.$inferSelect,
code: string,
redirectUri: string,
assertOwned: () => Promise<void>,
) {
const bot = await endpoint(session.endpointId, session.userId);
if (
!session.handoff?.identityExpiresAt ||
Date.parse(session.handoff.identityExpiresAt) <= Date.now()
)
throw conflict(
"GitHub identity authorization expired. Connect your account again.",
);
const [consumed] = await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`${chatGitHubRegistrations.handoff} - 'identityStateHash'`,
})
.where(
and(
eq(chatGitHubRegistrations.id, session.id),
sql`${chatGitHubRegistrations.handoff}->>'identityStateHash' = ${session.handoff?.identityStateHash ?? ""}`,
),
)
.returning();
if (!consumed) return resume(bot);
const material = await githubBotCredentials(db, bot.companyId, bot.id);
const response = await fetchImpl(
"https://github.com/login/oauth/access_token",
{
method: "POST",
redirect: "error",
signal: AbortSignal.timeout(25_000),
headers: {
accept: "application/json",
"content-type": "application/json",
},
body: JSON.stringify({
client_id: material.credentials.clientId,
client_secret: material.credentials.clientSecret,
code,
redirect_uri: redirectUri,
code_verifier: material.credentials.identityCodeVerifier,
}),
},
);
if (!response.ok) {
await response.body?.cancel();
throw conflict(
"GitHub authorization failed. Connect your account again.",
);
}
const token = (await response.json()) as { access_token?: string };
if (!token.access_token)
throw conflict(
"GitHub authorization was denied. Connect your account again.",
);
const user = await githubBotRequest<{
id: number;
login: string;
avatar_url?: string;
}>(fetchImpl, token.access_token, "/user");
let allowed = false;
for (let page = 1; page <= 100; page++) {
const result = await githubBotRequest<{
installations: Array<{ id: number; app_id: number }>;
}>(
fetchImpl,
token.access_token,
`/user/installations?per_page=100&page=${page}`,
);
allowed = result.installations.some(
(installation) =>
String(installation.app_id) === bot.botExternalId &&
String(installation.id) === material.credentials.installationId,
);
if (allowed || result.installations.length < 100) break;
}
if (!allowed)
throw forbidden(
"Your GitHub account cannot access this App installation",
);
if (
!Number.isSafeInteger(user.id) ||
user.id <= 0 ||
!/^[A-Za-z0-9-]{1,39}$/.test(user.login)
)
throw conflict("GitHub returned an invalid account");
// OAuth token remains transient; only an expiring observed identity is saved.
await assertOwned();
const identity = {
githubUserId: String(user.id),
login: user.login,
avatarUrl: user.avatar_url ?? null,
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
};
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`jsonb_set(${chatGitHubRegistrations.handoff}, '{identity}', ${JSON.stringify(identity)}::jsonb)`,
})
.where(eq(chatGitHubRegistrations.id, session.id));
await assertOwned();
await options.storeCredentials(bot.id, session.userId, {
identityCodeVerifier: nonce(),
});
return resume(bot);
}
async function directCallback(state: string, code: string) {
const [session] = await db
.select()
.from(chatGitHubRegistrations)
.where(eq(chatGitHubRegistrations.stateHash, hash(state)))
.limit(1);
if (
!session ||
session.handoff?.cloudId ||
session.trustedOrigin !== origin()
)
throw forbidden("GitHub return did not match this instance");
const bot = await endpoint(session.endpointId, session.userId);
if (session.status === "failed" || session.status === "exchanging")
return resume(bot);
if (session.status === "pending") {
if (!options.completeDirect)
throw conflict("GitHub registration is unavailable");
await options.completeDirect(state, code);
}
const progress = await advance(bot.id, session.userId);
return progress.state === "install" && progress.installationUrl
? progress.installationUrl
: resume(bot);
}
async function cloudCallback(
state: string,
registrationId: string,
claimId?: string,
) {
const [session] = await db
.select()
.from(chatGitHubRegistrations)
.where(
sql`${chatGitHubRegistrations.handoff}->>'cloudId' = ${registrationId}`,
)
.limit(1);
if (!session?.handoff || !claimId)
throw forbidden("GitHub return did not match this instance");
const cloud = connector();
if (!cloud) throw conflict("Restore Paperclip Cloud enrollment");
const opened = await cloud.claimGitHubApp({
subject: session.userId,
companyId: session.companyId,
claimId,
redemptionId:
session.handoff.identityStateHash &&
hash(state) === session.handoff.identityStateHash
? session.handoff.identityRedemptionId!
: session.handoff.redemptionId,
});
if (
opened.kind !== "gateway_callback" ||
opened.registrationId !== registrationId ||
(opened.callbackKind !== "install" && opened.state !== state)
)
throw forbidden("GitHub return did not match this draft");
const manifestReturn =
opened.callbackKind === "manifest" &&
session.stateHash === hash(String(opened.state ?? ""));
const identityReturn =
opened.callbackKind === "oauth" &&
session.handoff.identityStateHash === hash(String(opened.state ?? ""));
if (!manifestReturn && !identityReturn && opened.callbackKind !== "install")
throw forbidden("GitHub return state did not match");
const bot = await endpoint(session.endpointId, session.userId);
if (session.trustedOrigin !== origin()) {
const cloud = connector();
if (!cloud) throw forbidden("GitHub return did not match this instance");
const current = await cloud.githubApp({
subject: session.userId,
companyId: session.companyId,
binding: { action: "status", id: registrationId },
});
// A manager-authenticated Cloud lookup, rather than a callback parameter,
// proves the current enrolled destination after an instance rename.
if (current.returnOrigin !== origin())
throw forbidden("GitHub return did not match this instance");
await db
.update(chatGitHubRegistrations)
.set({ trustedOrigin: origin() })
.where(
and(
eq(chatGitHubRegistrations.id, session.id),
eq(chatGitHubRegistrations.trustedOrigin, session.trustedOrigin),
),
);
await logActivity(db, {
companyId: session.companyId,
actorType: "system",
actorId: "chat-github-setup",
action: "chat_github.registration_origin_recovered",
entityType: "tool_connection",
entityId: bot.connectionId,
details: { registrationId: session.id },
});
}
if (identityReturn) {
if (typeof opened.code !== "string" || opened.error) {
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`${chatGitHubRegistrations.handoff} - 'identityStateHash'`,
})
.where(
and(
eq(chatGitHubRegistrations.id, session.id),
sql`${chatGitHubRegistrations.handoff}->>'identityStateHash' = ${hash(state)}`,
),
);
return resume(bot);
}
const route = await cloud.githubApp({
subject: session.userId,
companyId: session.companyId,
binding: { action: "status", id: registrationId },
});
if (opened.redirectUri !== route.callbackUrls.oauth)
throw forbidden("GitHub identity callback did not match");
return completeIdentity(session, opened.code, route.callbackUrls.oauth);
}
if (manifestReturn && session.status === "pending") {
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`jsonb_set(${chatGitHubRegistrations.handoff}, '{manifestClaimId}', ${JSON.stringify(claimId)}::jsonb)`,
})
.where(
and(
eq(chatGitHubRegistrations.id, session.id),
eq(chatGitHubRegistrations.status, "pending"),
),
);
}
const progress = await advance(session.endpointId, session.userId);
return progress.state === "install" && progress.installationUrl
? progress.installationUrl
: resume(bot);
}
async function identityCallback(state: string, code: string) {
const [session] = await db
.select()
.from(chatGitHubRegistrations)
.where(
sql`${chatGitHubRegistrations.handoff}->>'identityStateHash' = ${hash(state)}`,
)
.limit(1);
if (!session || session.trustedOrigin !== origin())
throw forbidden("GitHub identity return did not match");
await endpoint(session.endpointId, session.userId);
if (!code) {
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`${chatGitHubRegistrations.handoff} - 'identityStateHash'`,
})
.where(
and(
eq(chatGitHubRegistrations.id, session.id),
sql`${chatGitHubRegistrations.handoff}->>'identityStateHash' = ${hash(state)}`,
),
);
return resume(await endpoint(session.endpointId, session.userId));
}
return completeIdentity(
session,
code,
`${origin()}/api/chat-github/identity/callback`,
);
}
async function confirmIdentity(
id: string,
userId: string,
githubUserId: string,
) {
const bot = await endpoint(id, userId),
session = await registration(bot),
candidate = session?.handoff?.identity;
if (
session?.userId !== userId ||
!candidate ||
candidate.githubUserId !== githubUserId ||
Date.parse(candidate.expiresAt) <= Date.now()
)
throw conflict(
"Connect your GitHub account again before confirming its identity",
);
await withIdentityLease(session.id, async (assertOwned) => {
const [current] = await db
.select()
.from(chatGitHubRegistrations)
.where(eq(chatGitHubRegistrations.id, session.id));
if (
current?.handoff?.identity?.githubUserId !== githubUserId ||
current.handoff.identity.expiresAt !== candidate.expiresAt
)
throw conflict("GitHub identity changed. Connect your account again.");
await assertOwned();
await management.linkObservedIdentity(id, userId, candidate);
await db
.update(chatGitHubRegistrations)
.set({ handoff: sql`${chatGitHubRegistrations.handoff} - 'identity'` })
.where(eq(chatGitHubRegistrations.id, session.id));
});
return advance(id, userId);
}
return {
saveDraft,
repairCloud,
start,
advance,
startIdentity,
directCallback,
cloudCallback,
identityCallback,
confirmIdentity,
};
}