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:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent 823824907c
commit 490c9a4ac9
11 files changed
+790

No files matched your search

+5
View File
@@ -2393,6 +2393,11 @@ export type {
AgentRepositoryGrantEntry,
EffectiveRepositoryProjectSource,
EffectiveRepositoryAccess,
RepositoryDiscoveryItem,
RepositoryDiscoveryPage,
BeginRepositoryConnectionResult,
CompleteRepositoryConnectionResult,
ImportRepositoriesResult,
} from "./types/repository.js";
export {
FEATURE_TIERS,
+42
View File
@@ -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();
});
});
+2
View File
@@ -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));
+246
View File
@@ -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(
+17
View File
@@ -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;
}