mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
823824907c
commit
490c9a4ac9
11 files changed
+790
No files matched your search
@@ -2393,6 +2393,11 @@ export type {
|
||||
AgentRepositoryGrantEntry,
|
||||
EffectiveRepositoryProjectSource,
|
||||
EffectiveRepositoryAccess,
|
||||
RepositoryDiscoveryItem,
|
||||
RepositoryDiscoveryPage,
|
||||
BeginRepositoryConnectionResult,
|
||||
CompleteRepositoryConnectionResult,
|
||||
ImportRepositoriesResult,
|
||||
} from "./types/repository.js";
|
||||
export {
|
||||
FEATURE_TIERS,
|
||||
|
||||
@@ -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<string, unknown> | 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[];
|
||||
}
|
||||
@@ -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<typeof createManualRepositorySchema>;
|
||||
export type UpdateRepository = z.infer<typeof updateRepositorySchema>;
|
||||
export type CreateRepositoryConnection = z.infer<typeof createRepositoryConnectionSchema>;
|
||||
export type AttachProjectRepository = z.infer<typeof attachProjectRepositorySchema>;
|
||||
export type BeginRepositoryConnection = z.infer<typeof beginRepositoryConnectionSchema>;
|
||||
export type CompleteRepositoryConnection = z.infer<typeof completeRepositoryConnectionSchema>;
|
||||
export type ImportRepositories = z.infer<typeof importRepositoriesSchema>;
|
||||
export type GrantAgentRepository = z.infer<typeof grantAgentRepositorySchema>;
|
||||
@@ -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<typeof createDb>,
|
||||
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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -125,6 +125,23 @@ export function repositoryService(db: Db) {
|
||||
return row ? toRepository(row) : null;
|
||||
},
|
||||
|
||||
findImportedProviderRepositories: async (
|
||||
companyId: string,
|
||||
connectionId: string,
|
||||
providerRepositoryIds: string[],
|
||||
): Promise<Repository[]> => {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, CachedToken>();
|
||||
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<string> {
|
||||
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<T>(installationId: string, path: string, init?: RequestInit): Promise<T> {
|
||||
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<GitHubInstallationToken> {
|
||||
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<GitHubInstallation | null> {
|
||||
try {
|
||||
return await appRequest<GitHubInstallation>(`/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<GitHubRepository>(
|
||||
input.installationId,
|
||||
`/repositories/${encodeURIComponent(input.providerRepositoryId)}`,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<RepositoryProviderConnector>;
|
||||
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<typeof repositoryProviderRegistry, "get"> = repositoryProviderRegistry,
|
||||
): RepositoryProviderConnector | null {
|
||||
const adapter = registry.get(provider);
|
||||
return isProviderConnector(adapter) ? adapter : null;
|
||||
}
|
||||
Reference in new issue
Block a user