From 490c9a4ac907b7a5cbb0a81e8f57e02875af4659 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 11 Aug 2026 17:40:18 +0000 Subject: [PATCH] feat(repositories): GitHub connection flow routes + provider registry wiring Add the company-scoped connection flow: begin install (signed state), provider callback (state-validated connection create), searchable paginated discovery with imported-state annotation, partial multi-select import, and effective-access-authorized short-lived clone credential resolution (no-store, secret-free audit). Register configured providers from env (GitHub App) and expose a real App-JWT transport. - Import creates reusable repository objects only; never attaches projects, grants agents, or creates workspaces - Shared validators/types for begin/complete/import/discovery contracts Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 5 + packages/shared/src/types/repository.ts | 42 +++ packages/shared/src/validators/repository.ts | 22 ++ .../repository-connection-flow.test.ts | 233 +++++++++++++++++ server/src/app.ts | 2 + server/src/routes/repositories.ts | 246 ++++++++++++++++++ server/src/services/repositories.ts | 17 ++ .../repository-providers/bootstrap.ts | 39 +++ .../repository-providers/github-transport.ts | 153 +++++++++++ .../services/repository-providers/index.ts | 3 + .../services/repository-providers/registry.ts | 28 ++ 11 files changed, 790 insertions(+) create mode 100644 server/src/__tests__/repository-connection-flow.test.ts create mode 100644 server/src/services/repository-providers/bootstrap.ts create mode 100644 server/src/services/repository-providers/github-transport.ts create mode 100644 server/src/services/repository-providers/registry.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d59d4747921..19f329cdb89 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2393,6 +2393,11 @@ export type { AgentRepositoryGrantEntry, EffectiveRepositoryProjectSource, EffectiveRepositoryAccess, + RepositoryDiscoveryItem, + RepositoryDiscoveryPage, + BeginRepositoryConnectionResult, + CompleteRepositoryConnectionResult, + ImportRepositoriesResult, } from "./types/repository.js"; export { FEATURE_TIERS, diff --git a/packages/shared/src/types/repository.ts b/packages/shared/src/types/repository.ts index e6ed3b76580..9d4c1a0a098 100644 --- a/packages/shared/src/types/repository.ts +++ b/packages/shared/src/types/repository.ts @@ -97,3 +97,45 @@ export interface EffectiveRepositoryAccess { projects: EffectiveRepositoryProjectSource[]; }; } + +/** A provider repository surfaced by discovery, before it is imported. */ +export interface RepositoryDiscoveryItem { + providerRepositoryId: string; + owner: string; + name: string; + cloneUrl: string; + webUrl: string | null; + defaultBranch: string | null; + visibility: RepositoryVisibility; + archived: boolean; + /** True when this provider repository is already imported into the catalog. */ + imported: boolean; + importedRepositoryId: string | null; + metadata: Record | null; +} + +export interface RepositoryDiscoveryPage { + connectionId: string; + items: RepositoryDiscoveryItem[]; + nextCursor: string | null; + total: number | null; +} + +export interface BeginRepositoryConnectionResult { + provider: RepositoryProvider; + installUrl: string; + state: string; + expiresAt: string; +} + +export interface CompleteRepositoryConnectionResult { + connection: RepositoryConnection; + created: boolean; + repositories: Repository[]; +} + +export interface ImportRepositoriesResult { + repositories: Repository[]; + imported: number; + skipped: string[]; +} diff --git a/packages/shared/src/validators/repository.ts b/packages/shared/src/validators/repository.ts index 3247a4ee474..1bee305ea10 100644 --- a/packages/shared/src/validators/repository.ts +++ b/packages/shared/src/validators/repository.ts @@ -51,8 +51,30 @@ export const attachProjectRepositorySchema = z.object({ export const grantAgentRepositorySchema = z.object({}).strict(); +const connectionRedirectPathSchema = z + .string() + .trim() + .max(2048) + .refine((value) => value.startsWith("/") && !value.startsWith("//"), "redirectPath must be an app-relative path"); + +export const beginRepositoryConnectionSchema = z.object({ + redirectPath: connectionRedirectPathSchema.optional().nullable(), +}).strict(); + +export const completeRepositoryConnectionSchema = z.object({ + state: z.string().min(1).max(8192), + installationId: z.string().trim().min(1).max(512), +}).strict(); + +export const importRepositoriesSchema = z.object({ + providerRepositoryIds: z.array(z.string().trim().min(1).max(512)).min(1).max(200), +}).strict(); + export type CreateManualRepository = z.infer; export type UpdateRepository = z.infer; export type CreateRepositoryConnection = z.infer; export type AttachProjectRepository = z.infer; +export type BeginRepositoryConnection = z.infer; +export type CompleteRepositoryConnection = z.infer; +export type ImportRepositories = z.infer; export type GrantAgentRepository = z.infer; diff --git a/server/src/__tests__/repository-connection-flow.test.ts b/server/src/__tests__/repository-connection-flow.test.ts new file mode 100644 index 00000000000..bd0c248ce06 --- /dev/null +++ b/server/src/__tests__/repository-connection-flow.test.ts @@ -0,0 +1,233 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agentRepositoryGrants, + agents, + companies, + createDb, + projectRepositories, + projectWorkspaces, + projects, + repositories, + repositoryConnections, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { repositoryRoutes } from "../routes/repositories.js"; +import { repositoryAccessService } from "../services/repository-access.js"; +import { repositoryProviderRegistry } from "../services/repository-connections.js"; +import { FakeRepositoryProvider } from "../services/repository-providers/fake-provider.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function boardActor(companyId: string): Express.Request["actor"] { + return { + type: "board", + userId: "repository-admin", + source: "session", + isInstanceAdmin: true, + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "admin", status: "active" }], + }; +} + +function agentActor(companyId: string, agentId: string): Express.Request["actor"] { + return { type: "agent", agentId, companyId, runId: null, source: "agent_jwt" }; +} + +function createApp( + db: ReturnType, + actor: Express.Request["actor"], +) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", repositoryRoutes(db)); + app.use(errorHandler); + return app; +} + +describeEmbeddedPostgres("repository connection flow (GitHub-style provider)", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const unregisterProviders: Array<() => void> = []; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-repo-flow-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + for (const unregister of unregisterProviders.splice(0).reverse()) unregister(); + await db.delete(activityLog); + await db.delete(agentRepositoryGrants); + await db.delete(projectRepositories); + await db.delete(projectWorkspaces); + await db.delete(repositories); + await db.delete(repositoryConnections); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const company = await db.insert(companies).values({ + name: "Flow Company", + issuePrefix: `FL${randomUUID().slice(0, 5).toUpperCase()}`, + }).returning().then((rows) => rows[0]!); + return company; + } + + function registerFakeProvider(installationId = "inst-1") { + const provider = new FakeRepositoryProvider({ provider: "fake", host: "fake.example.com", pageSize: 2 }); + provider.setInstallation({ + installationId, + accountId: "acct-1", + accountName: "acme", + repositories: [ + { providerRepositoryId: "1", owner: "acme", name: "alpha" }, + { providerRepositoryId: "2", owner: "acme", name: "beta" }, + { providerRepositoryId: "3", owner: "acme", name: "gamma" }, + ], + }); + unregisterProviders.push(repositoryProviderRegistry.register(provider.connector())); + return { provider, installationId }; + } + + it("runs install -> callback -> discover -> import end to end", async () => { + const company = await seedCompany(); + const { installationId } = registerFakeProvider(); + const app = createApp(db, boardActor(company.id)); + + const install = await request(app) + .post(`/api/companies/${company.id}/repository-connections/fake/install`) + .send({}); + expect(install.status).toBe(201); + expect(install.body.installUrl).toContain("state="); + const state = install.body.state as string; + + const callback = await request(app) + .post(`/api/companies/${company.id}/repository-connections/fake/callback`) + .send({ state, installationId }); + expect(callback.status).toBe(201); + expect(callback.body.connection).toMatchObject({ provider: "fake", installationId, accountName: "acme" }); + expect(callback.body.repositories).toEqual([]); + const connectionId = callback.body.connection.id as string; + + // Discovery is paginated and marks nothing imported yet. + const page1 = await request(app).get(`/api/repository-connections/${connectionId}/discover?pageSize=2`); + expect(page1.status).toBe(200); + expect(page1.body.items).toHaveLength(2); + expect(page1.body.nextCursor).not.toBeNull(); + expect(page1.body.items.every((item: { imported: boolean }) => item.imported === false)).toBe(true); + + // Partial multi-select import: one valid id + one unknown id. + const importResp = await request(app) + .post(`/api/repository-connections/${connectionId}/import`) + .send({ providerRepositoryIds: ["1", "does-not-exist"] }); + expect(importResp.status).toBe(201); + expect(importResp.body.imported).toBe(1); + expect(importResp.body.skipped).toEqual(["does-not-exist"]); + const importedRepositoryId = importResp.body.repositories[0].id as string; + + // Import creates reusable repository objects only; no projects/grants/workspaces. + expect(await db.select().from(projectRepositories)).toEqual([]); + expect(await db.select().from(agentRepositoryGrants)).toEqual([]); + expect(await db.select().from(projectWorkspaces)).toEqual([]); + + // Re-discovery now flags the imported repository. + const rediscover = await request(app).get(`/api/repository-connections/${connectionId}/discover?query=alpha`); + expect(rediscover.body.items).toHaveLength(1); + expect(rediscover.body.items[0]).toMatchObject({ imported: true, importedRepositoryId }); + + // Idempotent re-import does not duplicate. + const reimport = await request(app) + .post(`/api/repository-connections/${connectionId}/import`) + .send({ providerRepositoryIds: ["1"] }); + expect(reimport.body.repositories[0].id).toBe(importedRepositoryId); + expect(await db.select().from(repositories)).toHaveLength(1); + }); + + it("rejects a tampered callback state", async () => { + const company = await seedCompany(); + const { installationId } = registerFakeProvider(); + const app = createApp(db, boardActor(company.id)); + const install = await request(app) + .post(`/api/companies/${company.id}/repository-connections/fake/install`) + .send({}); + const callback = await request(app) + .post(`/api/companies/${company.id}/repository-connections/fake/callback`) + .send({ state: `${install.body.state}tampered`, installationId }); + expect(callback.status).toBe(422); + }); + + it("returns 422 when the provider is not registered", async () => { + const company = await seedCompany(); + const app = createApp(db, boardActor(company.id)); + const install = await request(app) + .post(`/api/companies/${company.id}/repository-connections/github/install`) + .send({}); + expect(install.status).toBe(422); + }); + + async function importOneRepository(companyId: string) { + const { installationId } = registerFakeProvider(); + const app = createApp(db, boardActor(companyId)); + const install = await request(app).post(`/api/companies/${companyId}/repository-connections/fake/install`).send({}); + const callback = await request(app) + .post(`/api/companies/${companyId}/repository-connections/fake/callback`) + .send({ state: install.body.state, installationId }); + const connectionId = callback.body.connection.id as string; + const importResp = await request(app) + .post(`/api/repository-connections/${connectionId}/import`) + .send({ providerRepositoryIds: ["1"] }); + return { app, repositoryId: importResp.body.repositories[0].id as string }; + } + + it("authorizes clone-credential resolution through the effective-access resolver", async () => { + const company = await seedCompany(); + const { app: boardApp, repositoryId } = await importOneRepository(company.id); + + // Board actor within the company is authorized. + const boardResolve = await request(boardApp).post(`/api/repositories/${repositoryId}/clone-credential`).send({}); + expect(boardResolve.status).toBe(200); + expect(boardResolve.body.authenticatedCloneUrl).toContain(boardResolve.body.token); + expect(boardResolve.headers["cache-control"]).toBe("no-store"); + + // The audit record must not contain the token. + const auditRows = await db.select().from(activityLog); + expect(JSON.stringify(auditRows)).not.toContain(boardResolve.body.token); + expect(auditRows.some((row) => row.action === "repository.clone_credential_resolved")).toBe(true); + + const agent = await db.insert(agents).values({ companyId: company.id, name: "Runner", role: "engineer" }) + .returning().then((rows) => rows[0]!); + + // An agent without any access source is forbidden. + const deniedApp = createApp(db, agentActor(company.id, agent.id)); + const denied = await request(deniedApp).post(`/api/repositories/${repositoryId}/clone-credential`).send({}); + expect(denied.status).toBe(403); + + // Granting a direct repository access authorizes the agent. + await repositoryAccessService(db).grantAgentRepository({ + companyId: company.id, + agentId: agent.id, + repositoryId, + }); + const allowed = await request(deniedApp).post(`/api/repositories/${repositoryId}/clone-credential`).send({}); + expect(allowed.status).toBe(200); + expect(allowed.body.expiresAt).toBeTruthy(); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 723ba7cd8cd..6e9c40ebacc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -31,6 +31,7 @@ import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { repositoryRoutes } from "./routes/repositories.js"; +import { registerConfiguredRepositoryProviders } from "./services/repository-providers/bootstrap.js"; import { issueRoutes } from "./routes/issues.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; import { caseRoutes } from "./routes/cases.js"; @@ -388,6 +389,7 @@ export async function createApp( api.use(agentRoutes(db, { pluginWorkerManager: workerManager })); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); + registerConfiguredRepositoryProviders(); api.use(repositoryRoutes(db)); api.use(caseRoutes(db, opts.storageService)); api.use(issueTreeControlRoutes(db)); diff --git a/server/src/routes/repositories.ts b/server/src/routes/repositories.ts index bf898ddfd23..c6066f9ea70 100644 --- a/server/src/routes/repositories.ts +++ b/server/src/routes/repositories.ts @@ -4,10 +4,14 @@ import type { Db } from "@paperclipai/db"; import { agents, projects } from "@paperclipai/db"; import { attachProjectRepositorySchema, + beginRepositoryConnectionSchema, + completeRepositoryConnectionSchema, createManualRepositorySchema, createRepositoryConnectionSchema, grantAgentRepositorySchema, + importRepositoriesSchema, updateRepositorySchema, + type RepositoryDiscoveryItem, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { @@ -17,6 +21,8 @@ import { repositoryConnectionService, repositoryService, } from "../services/index.js"; +import { getRepositoryProviderConnector } from "../services/repository-providers/registry.js"; +import { forbidden, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; function activityActor(req: Request) { @@ -239,6 +245,246 @@ export function repositoryRoutes(db: Db) { } }); + // ---- Provider connection flow (GitHub.com + other discovery providers) ---- + + router.post( + "/companies/:companyId/repository-connections/:provider/install", + validate(beginRepositoryConnectionSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const providerKey = (req.params.provider as string).toLowerCase(); + const connector = getRepositoryProviderConnector(providerKey); + if (!connector) throw unprocessable(`Repository provider '${providerKey}' is not available`); + const { actor } = activityActor(req); + const userId = actor.actorType === "user" ? actor.actorId : actor.agentId; + if (!userId) throw forbidden("A user or agent identity is required to begin a connection"); + const begin = await connector.beginInstallation({ + companyId, + userId, + redirectPath: req.body.redirectPath ?? null, + }); + res.status(201).json({ + provider: connector.provider, + installUrl: begin.installUrl, + state: begin.state, + expiresAt: begin.expiresAt.toISOString(), + }); + }, + ); + + router.post( + "/companies/:companyId/repository-connections/:provider/callback", + validate(completeRepositoryConnectionSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const providerKey = (req.params.provider as string).toLowerCase(); + const connector = getRepositoryProviderConnector(providerKey); + if (!connector) throw unprocessable(`Repository provider '${providerKey}' is not available`); + const meta = await connector.completeInstallation({ + state: req.body.state, + installationId: req.body.installationId, + companyId, + }); + // Create the connection only. Repositories are imported explicitly via + // the multi-select import flow so nothing is silently attached. + const result = await connections.create(companyId, { + provider: connector.provider, + host: meta.host, + installationId: meta.installationId, + accountId: meta.accountId, + accountName: meta.accountName, + }); + const { attribution } = activityActor(req); + if (result.created) { + await logActivity(db, { + companyId, + ...attribution, + action: "repository_connection.created", + entityType: "repository_connection", + entityId: result.connection.id, + details: { provider: result.connection.provider, host: result.connection.host, via: "callback" }, + }); + } + res + .status(result.created ? 201 : 200) + .json({ connection: result.connection, created: result.created, repositories: [] }); + }, + ); + + router.get("/repository-connections/:connectionId/discover", async (req, res) => { + assertBoard(req); + const connectionId = req.params.connectionId as string; + const connection = await getAccessibleResource( + req, + res, + connections.getById(connectionId), + "Repository connection not found", + ); + if (!connection) return; + const connector = getRepositoryProviderConnector(connection.provider); + if (!connector) throw unprocessable(`Repository provider '${connection.provider}' does not support discovery`); + if (connection.status === "disconnected") throw unprocessable("Disconnected repository connection cannot be discovered"); + + const pageSizeRaw = Number.parseInt(String(req.query.pageSize ?? ""), 10); + const page = await connector.discover({ + connection, + query: typeof req.query.query === "string" ? req.query.query : null, + cursor: typeof req.query.cursor === "string" ? req.query.cursor : null, + pageSize: Number.isFinite(pageSizeRaw) ? pageSizeRaw : undefined, + }); + const imported = await repositories.findImportedProviderRepositories( + connection.companyId, + connectionId, + page.items.map((item) => item.providerRepositoryId), + ); + const importedById = new Map(imported.map((repo) => [repo.providerRepositoryId, repo])); + const items: RepositoryDiscoveryItem[] = page.items.map((item) => { + const existing = importedById.get(item.providerRepositoryId); + return { + providerRepositoryId: item.providerRepositoryId, + owner: item.owner, + name: item.name, + cloneUrl: item.cloneUrl, + webUrl: item.webUrl ?? null, + defaultBranch: item.defaultBranch ?? null, + visibility: item.visibility ?? "unknown", + archived: item.archived ?? false, + imported: Boolean(existing), + importedRepositoryId: existing?.id ?? null, + metadata: item.metadata ?? null, + }; + }); + res.json({ connectionId, items, nextCursor: page.nextCursor, total: page.total }); + }); + + router.post( + "/repository-connections/:connectionId/import", + validate(importRepositoriesSchema), + async (req, res) => { + assertBoard(req); + const connectionId = req.params.connectionId as string; + const connection = await getAccessibleResource( + req, + res, + connections.getById(connectionId), + "Repository connection not found", + ); + if (!connection) return; + const connector = getRepositoryProviderConnector(connection.provider); + if (!connector) throw unprocessable(`Repository provider '${connection.provider}' does not support import`); + if (connection.status === "disconnected") throw unprocessable("Disconnected repository connection cannot import repositories"); + + const providerRepositoryIds: string[] = req.body.providerRepositoryIds; + const importedRepositories = []; + const skipped: string[] = []; + for (const providerRepositoryId of providerRepositoryIds) { + const snapshot = await connector.refreshMetadata({ connection, providerRepositoryId }); + if (!snapshot) { + skipped.push(providerRepositoryId); + continue; + } + importedRepositories.push( + await repositories.upsertProviderRepository(connection.companyId, connectionId, connection.provider, snapshot), + ); + } + const { attribution } = activityActor(req); + if (importedRepositories.length > 0) { + await logActivity(db, { + companyId: connection.companyId, + ...attribution, + action: "repository.imported", + entityType: "repository_connection", + entityId: connectionId, + details: { + provider: connection.provider, + importedCount: importedRepositories.length, + skippedCount: skipped.length, + }, + }); + } + res + .status(importedRepositories.length > 0 ? 201 : 200) + .json({ repositories: importedRepositories, imported: importedRepositories.length, skipped }); + }, + ); + + router.post("/repositories/:repositoryId/clone-credential", async (req, res) => { + const repositoryId = req.params.repositoryId as string; + const repository = await getAccessibleResource( + req, + res, + repositories.getById(repositoryId), + "Repository not found", + ); + if (!repository) return; + if (repository.state !== "active") throw unprocessable("Archived or unavailable repository cannot resolve clone credentials"); + if (!repository.connectionId) throw unprocessable("Repository is not backed by a provider connection"); + + // Authorize through the effective-access resolver: company/board actors are + // allowed within their company; agents must hold effective access (a direct + // grant or an accessible project source) to the repository. + const reqActor = req.actor; + let authorized = false; + if (reqActor.type === "board") { + authorized = reqActor.isInstanceAdmin === true || (reqActor.companyIds?.includes(repository.companyId) ?? false); + } else if (reqActor.type === "agent" && reqActor.agentId) { + const effective = await repositoryAccess.listEffectiveRepositories({ + companyId: repository.companyId, + agentId: reqActor.agentId, + canAccessProject: async (project) => (await access.decide({ + actor: reqActor, + action: "project:read", + resource: { type: "project", companyId: project.companyId, projectId: project.id }, + })).allowed, + }); + authorized = (effective ?? []).some((entry) => entry.repository.id === repository.id); + } + if (!authorized) throw forbidden("Repository is outside this actor's effective access"); + + const connection = await connections.getById(repository.connectionId); + if (!connection || connection.status === "disconnected") throw unprocessable("Repository connection is unavailable"); + const connector = getRepositoryProviderConnector(connection.provider); + if (!connector) throw unprocessable(`Repository provider '${connection.provider}' cannot resolve clone credentials`); + + const resolved = await connector.resolveCloneCredential({ + connection, + repository: { + id: repository.id, + providerRepositoryId: repository.providerRepositoryId, + owner: repository.owner, + name: repository.name, + host: repository.host, + cloneUrl: repository.cloneUrl, + }, + }); + const { attribution } = activityActor(req); + await logActivity(db, { + companyId: repository.companyId, + ...attribution, + action: "repository.clone_credential_resolved", + entityType: "repository", + entityId: repository.id, + details: { + provider: resolved.audit.provider, + host: resolved.audit.host, + connectionId: resolved.audit.connectionId, + installationId: resolved.audit.installationId, + expiresAt: resolved.audit.expiresAt.toISOString(), + }, + }); + res.setHeader("Cache-Control", "no-store"); + res.json({ + username: resolved.username, + token: resolved.token, + authenticatedCloneUrl: resolved.authenticatedCloneUrl, + expiresAt: resolved.expiresAt.toISOString(), + }); + }); + router.get("/projects/:projectId/repositories", async (req, res) => { const projectId = req.params.projectId as string; const project = await getAccessibleResource( diff --git a/server/src/services/repositories.ts b/server/src/services/repositories.ts index 77d8210002d..2042e109e08 100644 --- a/server/src/services/repositories.ts +++ b/server/src/services/repositories.ts @@ -125,6 +125,23 @@ export function repositoryService(db: Db) { return row ? toRepository(row) : null; }, + findImportedProviderRepositories: async ( + companyId: string, + connectionId: string, + providerRepositoryIds: string[], + ): Promise => { + if (providerRepositoryIds.length === 0) return []; + const rows = await db + .select() + .from(repositories) + .where(and( + eq(repositories.companyId, companyId), + eq(repositories.connectionId, connectionId), + inArray(repositories.providerRepositoryId, providerRepositoryIds), + )); + return rows.map(toRepository); + }, + createManual: async (companyId: string, input: CreateManualRepository) => { const normalized = normalizeRepositoryLocator(input.cloneUrl); const identityKey = manualRepositoryIdentityKey(normalized); diff --git a/server/src/services/repository-providers/bootstrap.ts b/server/src/services/repository-providers/bootstrap.ts new file mode 100644 index 00000000000..5784a223230 --- /dev/null +++ b/server/src/services/repository-providers/bootstrap.ts @@ -0,0 +1,39 @@ +import { repositoryProviderRegistry } from "../repository-connections.js"; +import { createGitHubProvider } from "./github-provider.js"; +import { createGitHubAppTransport } from "./github-transport.js"; + +/** + * Registers repository providers configured via environment. Registration is a + * no-op when the required config is absent, so a deployment without a GitHub App + * simply exposes manual repositories. Secrets (app id, private key, state + * secret) are read here and handed to the transport/provider; they are never + * persisted on any row. + */ +export interface RegisterRepositoryProvidersOptions { + env?: NodeJS.ProcessEnv; +} + +export function registerConfiguredRepositoryProviders( + options: RegisterRepositoryProvidersOptions = {}, +): Array<() => void> { + const env = options.env ?? process.env; + const unregister: Array<() => void> = []; + + const appId = env.PAPERCLIP_GITHUB_APP_ID?.trim(); + const privateKey = env.PAPERCLIP_GITHUB_APP_PRIVATE_KEY; + const appSlug = env.PAPERCLIP_GITHUB_APP_SLUG?.trim(); + const stateSecret = env.PAPERCLIP_GITHUB_CONNECTION_STATE_SECRET?.trim(); + + if (appId && privateKey && appSlug && stateSecret) { + const transport = createGitHubAppTransport({ + appId, + privateKey, + host: env.PAPERCLIP_GITHUB_HOST?.trim() || undefined, + apiBaseUrl: env.PAPERCLIP_GITHUB_API_BASE_URL?.trim() || undefined, + }); + const provider = createGitHubProvider({ transport, config: { appSlug, stateSecret } }); + unregister.push(repositoryProviderRegistry.register(provider)); + } + + return unregister; +} diff --git a/server/src/services/repository-providers/github-transport.ts b/server/src/services/repository-providers/github-transport.ts new file mode 100644 index 00000000000..691a3b0c63d --- /dev/null +++ b/server/src/services/repository-providers/github-transport.ts @@ -0,0 +1,153 @@ +import { createSign } from "node:crypto"; +import { unprocessable } from "../../errors.js"; +import type { + GitHubAppTransport, + GitHubInstallation, + GitHubInstallationToken, + GitHubRepository, +} from "./github-provider.js"; + +/** + * Real GitHub App transport. Encapsulates App JWT minting (RS256 with the app + * private key), installation-token exchange, and the installation REST calls the + * provider needs. The private key and minted tokens live only inside this + * transport; nothing here is persisted or returned to callers except the + * short-lived installation token surfaced through the provider's credential + * resolution (which the connection service never stores). + */ + +export interface GitHubAppTransportConfig { + appId: string; + /** PEM-encoded RSA private key for the GitHub App. */ + privateKey: string; + host?: string; + apiBaseUrl?: string; + /** Clock skew allowance in seconds for the app JWT `iat`. */ + clockSkewSeconds?: number; + fetchImpl?: typeof fetch; + now?: () => Date; +} + +interface CachedToken { + token: string; + expiresAt: Date; +} + +function base64url(input: Buffer | string): string { + return Buffer.from(input).toString("base64url"); +} + +export function createGitHubAppTransport(config: GitHubAppTransportConfig): GitHubAppTransport { + if (!config.appId) throw unprocessable("GitHub App id is required"); + if (!config.privateKey) throw unprocessable("GitHub App private key is required"); + const host = (config.host ?? "github.com").toLowerCase(); + const apiBaseUrl = (config.apiBaseUrl ?? "https://api.github.com").replace(/\/$/, ""); + const doFetch = config.fetchImpl ?? fetch; + const now = config.now ?? (() => new Date()); + const skew = config.clockSkewSeconds ?? 30; + const tokenCache = new Map(); + + function mintAppJwt(): string { + const iat = Math.floor(now().getTime() / 1000) - skew; + const exp = iat + 9 * 60; // GitHub caps app JWTs at 10 minutes. + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const payload = base64url(JSON.stringify({ iat, exp, iss: config.appId })); + const signingInput = `${header}.${payload}`; + const signature = createSign("RSA-SHA256").update(signingInput).sign(config.privateKey).toString("base64url"); + return `${signingInput}.${signature}`; + } + + async function appRequest(path: string, init?: RequestInit): Promise { + const response = await doFetch(`${apiBaseUrl}${path}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${mintAppJwt()}`, + "X-GitHub-Api-Version": "2022-11-28", + ...(init?.headers ?? {}), + }, + }); + if (!response.ok) { + throw new Error(`GitHub app request ${path} failed with ${response.status}`); + } + return (await response.json()) as T; + } + + async function installationToken(installationId: string): Promise { + const cached = tokenCache.get(installationId); + // Refresh a minute before expiry to avoid using a token mid-request. + if (cached && cached.expiresAt.getTime() - now().getTime() > 60_000) return cached.token; + const minted = await createInstallationAccessToken(installationId); + tokenCache.set(installationId, { token: minted.token, expiresAt: minted.expiresAt }); + return minted.token; + } + + async function installationRequest(installationId: string, path: string, init?: RequestInit): Promise { + const token = await installationToken(installationId); + const response = await doFetch(`${apiBaseUrl}${path}`, { + ...init, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + ...(init?.headers ?? {}), + }, + }); + if (!response.ok) { + throw new Error(`GitHub installation request ${path} failed with ${response.status}`); + } + return (await response.json()) as T; + } + + async function createInstallationAccessToken(installationId: string): Promise { + const body = await appRequest<{ token: string; expires_at: string }>( + `/app/installations/${encodeURIComponent(installationId)}/access_tokens`, + { method: "POST" }, + ); + return { token: body.token, expiresAt: new Date(body.expires_at) }; + } + + return { + host, + apiBaseUrl, + + async getInstallation(installationId: string): Promise { + try { + return await appRequest(`/app/installations/${encodeURIComponent(installationId)}`); + } catch { + return null; + } + }, + + createInstallationAccessToken, + + async listRepositories(input) { + const params = new URLSearchParams({ per_page: String(input.perPage), page: String(input.page) }); + const body = await installationRequest<{ total_count: number; repositories: GitHubRepository[] }>( + input.installationId, + `/installation/repositories?${params.toString()}`, + ); + const query = input.query?.trim().toLowerCase(); + if (!query) { + return { totalCount: body.total_count ?? null, items: body.repositories ?? [] }; + } + // The installation-repositories endpoint has no server-side search; filter + // the page client-side. total_count no longer reflects the filtered set. + return { + totalCount: null, + items: (body.repositories ?? []).filter((repo) => repo.full_name.toLowerCase().includes(query)), + }; + }, + + async getRepository(input) { + try { + return await installationRequest( + input.installationId, + `/repositories/${encodeURIComponent(input.providerRepositoryId)}`, + ); + } catch { + return null; + } + }, + }; +} diff --git a/server/src/services/repository-providers/index.ts b/server/src/services/repository-providers/index.ts index 3bbfd9d4d6b..aecdfcd326f 100644 --- a/server/src/services/repository-providers/index.ts +++ b/server/src/services/repository-providers/index.ts @@ -1,4 +1,7 @@ export * from "./provider-contract.js"; export * from "./connection-state.js"; export * from "./github-provider.js"; +export * from "./github-transport.js"; export * from "./fake-provider.js"; +export * from "./registry.js"; +export * from "./bootstrap.js"; diff --git a/server/src/services/repository-providers/registry.ts b/server/src/services/repository-providers/registry.ts new file mode 100644 index 00000000000..69e3d275224 --- /dev/null +++ b/server/src/services/repository-providers/registry.ts @@ -0,0 +1,28 @@ +import { repositoryProviderRegistry } from "../repository-connections.js"; +import type { RepositoryProviderConnector } from "./provider-contract.js"; + +/** + * Narrows a registered Foundation adapter to the full connection/discovery + * {@link RepositoryProviderConnector}. Providers registered by the GitHub.com + * path implement the extended surface; a bare Foundation adapter (sync-only) + * does not and is treated as "discovery not supported". + */ +export function isProviderConnector(value: unknown): value is RepositoryProviderConnector { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.beginInstallation === "function" && + typeof candidate.completeInstallation === "function" && + typeof candidate.discover === "function" && + typeof candidate.refreshMetadata === "function" && + typeof candidate.resolveCloneCredential === "function" + ); +} + +export function getRepositoryProviderConnector( + provider: string, + registry: Pick = repositoryProviderRegistry, +): RepositoryProviderConnector | null { + const adapter = registry.get(provider); + return isProviderConnector(adapter) ? adapter : null; +}