fix(repositories): preserve imports across connections

Keep provider identities imported across all matching connections without reassigning catalog ownership, report only newly created rows, renumber the migration after rebasing on master, and consolidate provider test scaffolding to stay within the PR file limit.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent 180494068a
commit f48a7d2f91
19 files changed
+40474 -409

No files matched your search

@@ -1,80 +0,0 @@
import { generateKeyPairSync } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import { createGitHubAppTransport } from "../services/repository-providers/github-transport.js";
import { makeGitHubRepository } from "./helpers/fake-github-transport.js";
const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 })
.privateKey.export({ type: "pkcs8", format: "pem" })
.toString();
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("GitHub App transport", () => {
it("searches the complete installation catalog before paginating matches", async () => {
const repositories = Array.from({ length: 205 }, (_, index) => makeGitHubRepository({
id: index + 1,
owner: index === 100 || index === 101 || index === 200 ? "matching-owner" : "other-owner",
name: `repo-${index + 1}`,
}));
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url);
if (url.pathname.endsWith("/access_tokens")) {
return jsonResponse({ token: "ghs_test", expires_at: "2099-01-01T00:00:00.000Z" });
}
const page = Number(url.searchParams.get("page"));
const perPage = Number(url.searchParams.get("per_page"));
const start = (page - 1) * perPage;
return jsonResponse({ total_count: repositories.length, repositories: repositories.slice(start, start + perPage) });
});
const transport = createGitHubAppTransport({ appId: "1", privateKey, fetchImpl });
await expect(transport.listRepositories({
installationId: "installation-1",
page: 1,
perPage: 2,
query: "matching-owner",
})).resolves.toMatchObject({
totalCount: 3,
items: [{ id: 101 }, { id: 102 }],
});
await expect(transport.listRepositories({
installationId: "installation-1",
page: 2,
perPage: 2,
query: "matching-owner",
})).resolves.toMatchObject({
totalCount: 3,
items: [{ id: 201 }],
});
expect(fetchImpl.mock.calls.some(([input]) => String(input).includes("per_page=100&page=3"))).toBe(true);
});
it("does not expose provider request paths in operator-facing failures", async () => {
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url);
if (url.pathname.endsWith("/access_tokens")) {
return jsonResponse({ token: "ghs_test", expires_at: "2099-01-01T00:00:00.000Z" });
}
return jsonResponse({ message: "unavailable" }, 503);
});
const transport = createGitHubAppTransport({ appId: "1", privateKey, fetchImpl });
await expect(transport.listRepositories({
installationId: "sensitive-installation",
page: 1,
perPage: 20,
query: null,
})).rejects.toThrow("GitHub repository provider request failed with status 503");
await expect(transport.listRepositories({
installationId: "sensitive-installation",
page: 1,
perPage: 20,
query: null,
})).rejects.not.toThrow("sensitive-installation");
});
});
@@ -1,9 +1,13 @@
import { randomUUID } from "node:crypto";
import { expect, it } from "vitest";
import type { RepositoryConnection } from "@paperclipai/shared";
import type {
GitHubAppTransport,
GitHubInstallation,
GitHubInstallationToken,
GitHubRepository,
} from "../../services/repository-providers/github-provider.js";
import type { RepositoryProviderConnector } from "../../services/repository-providers/provider-contract.js";
/**
* Controllable in-memory GitHub App transport for provider tests. It stands in
@@ -106,3 +110,184 @@ export function makeGitHubRepository(overrides: Partial<GitHubRepository> & { id
...overrides,
};
}
export interface ConformanceSeedRepo {
providerRepositoryId: string;
owner: string;
name: string;
}
export interface ProviderConformanceWorld {
connector: RepositoryProviderConnector;
companyId: string;
userId: string;
installationId: string;
accountName: string;
seed(repos: ConformanceSeedRepo[]): void;
rename(providerRepositoryId: string, next: { owner: string; name: string }): void;
}
export type ConformanceWorldFactory = () => ProviderConformanceWorld;
function connectionFromMetadata(
world: ProviderConformanceWorld,
meta: {
installationId: string;
accountId: string | null;
accountName: string | null;
host: string;
providerMetadata: Record<string, unknown> | null;
},
): RepositoryConnection {
const now = new Date();
return {
id: randomUUID(),
companyId: world.companyId,
provider: world.connector.provider,
host: meta.host,
installationId: meta.installationId,
accountId: meta.accountId,
accountName: meta.accountName,
status: "active",
syncStatus: "idle",
syncCursor: null,
syncError: null,
lastSyncedAt: null,
providerMetadata: meta.providerMetadata,
disconnectedAt: null,
createdAt: now,
updatedAt: now,
};
}
/** Registers reusable conformance cases for any provider connector. */
export function runProviderConformance(makeWorld: ConformanceWorldFactory) {
async function install(world: ProviderConformanceWorld): Promise<RepositoryConnection> {
const begin = await world.connector.beginInstallation({ companyId: world.companyId, userId: world.userId });
expect(begin.installUrl).toContain("state=");
expect(begin.expiresAt.getTime()).toBeGreaterThan(Date.now());
const meta = await world.connector.completeInstallation({
state: begin.state,
installationId: world.installationId,
companyId: world.companyId,
});
expect(meta.installationId).toBe(world.installationId);
expect(meta.accountName).toBe(world.accountName);
return connectionFromMetadata(world, meta);
}
it("completes installation with a valid signed state and rejects tampered/mismatched state", async () => {
const world = makeWorld();
world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]);
const begin = await world.connector.beginInstallation({ companyId: world.companyId, userId: world.userId });
await expect(world.connector.completeInstallation({
state: `${begin.state}tampered`,
installationId: world.installationId,
companyId: world.companyId,
})).rejects.toMatchObject({ status: 422 });
await expect(world.connector.completeInstallation({
state: begin.state,
installationId: world.installationId,
companyId: "00000000-0000-0000-0000-000000000000",
})).rejects.toMatchObject({ status: expect.any(Number) });
const meta = await world.connector.completeInstallation({
state: begin.state,
installationId: world.installationId,
companyId: world.companyId,
});
expect(meta.companyId).toBe(world.companyId);
expect(meta.userId).toBe(world.userId);
});
it("discovers repositories with pagination and search", async () => {
const world = makeWorld();
world.seed([
{ providerRepositoryId: "1", owner: "acme", name: "alpha" },
{ providerRepositoryId: "2", owner: "acme", name: "beta" },
{ providerRepositoryId: "3", owner: "acme", name: "gamma" },
]);
const connection = await install(world);
const page1 = await world.connector.discover({ connection, pageSize: 2 });
expect(page1.items).toHaveLength(2);
expect(page1.nextCursor).not.toBeNull();
expect(page1.total).toBe(3);
const page2 = await world.connector.discover({ connection, pageSize: 2, cursor: page1.nextCursor });
expect(page2.items).toHaveLength(1);
expect(page2.nextCursor).toBeNull();
const allIds = [...page1.items, ...page2.items].map((repository) => repository.providerRepositoryId).sort();
expect(allIds).toEqual(["1", "2", "3"]);
const searched = await world.connector.discover({ connection, query: "beta", pageSize: 10 });
expect(searched.items.map((repository) => repository.name)).toEqual(["beta"]);
});
it("maps discovery items to importable snapshots with stable provider ids", async () => {
const world = makeWorld();
world.seed([{ providerRepositoryId: "77", owner: "acme", name: "delta" }]);
const connection = await install(world);
const discovered = (await world.connector.discover({ connection, pageSize: 10 })).items;
expect(discovered[0]).toMatchObject({ providerRepositoryId: "77", owner: "acme", name: "delta" });
expect(discovered[0]!.cloneUrl).toContain("acme/delta");
});
it("refreshes metadata for a known repository and returns null for unknown ids", async () => {
const world = makeWorld();
world.seed([{ providerRepositoryId: "5", owner: "acme", name: "epsilon" }]);
const connection = await install(world);
const refreshed = await world.connector.refreshMetadata({ connection, providerRepositoryId: "5" });
expect(refreshed).toMatchObject({ providerRepositoryId: "5" });
expect(await world.connector.refreshMetadata({
connection,
providerRepositoryId: "does-not-exist",
})).toBeNull();
});
it("converges idempotently across sync and reflects rename/transfer by stable id", async () => {
const world = makeWorld();
world.seed([
{ providerRepositoryId: "10", owner: "acme", name: "one" },
{ providerRepositoryId: "11", owner: "acme", name: "two" },
]);
const connection = await install(world);
const first = await world.connector.sync({ connection, cursor: null });
expect(first.repositories.map((repository) => repository.providerRepositoryId).sort()).toEqual(["10", "11"]);
const second = await world.connector.sync({ connection, cursor: null });
expect(second.repositories.map((repository) => repository.providerRepositoryId).sort()).toEqual(["10", "11"]);
world.rename("10", { owner: "acme-new", name: "one-renamed" });
const afterRename = await world.connector.sync({ connection, cursor: null });
const renamed = afterRename.repositories.find((repository) => repository.providerRepositoryId === "10");
expect(renamed?.cloneUrl).toContain("acme-new/one-renamed");
});
it("resolves a short-lived clone credential without leaking secrets into the audit record", async () => {
const world = makeWorld();
world.seed([{ providerRepositoryId: "9", owner: "acme", name: "zeta" }]);
const connection = await install(world);
const result = await world.connector.resolveCloneCredential({
connection,
repository: {
id: "repo-uuid",
providerRepositoryId: "9",
owner: "acme",
name: "zeta",
host: connection.host,
cloneUrl: `https://${connection.host}/acme/zeta.git`,
},
});
expect(result.token).toBeTruthy();
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
expect(result.authenticatedCloneUrl).toContain(result.token);
const auditJson = JSON.stringify(result.audit);
expect(auditJson).not.toContain(result.token);
expect(result.audit).toMatchObject({ repositoryId: "repo-uuid", connectionId: connection.id });
});
}
@@ -1,191 +0,0 @@
import { randomUUID } from "node:crypto";
import { expect, it } from "vitest";
import type { RepositoryConnection } from "@paperclipai/shared";
import type { RepositoryProviderConnector } from "../../services/repository-providers/provider-contract.js";
/**
* Reusable provider conformance harness. Any {@link RepositoryProviderConnector}
* implementation — the in-memory fake or the real GitHub.com provider backed by
* a fake transport — must pass this suite. It asserts installation lifecycle,
* signed-state validation, searchable/paginated discovery, metadata refresh,
* rename/transfer convergence via sync, and short-lived clone credential
* resolution (secret-free audit).
*/
export interface ConformanceSeedRepo {
providerRepositoryId: string;
owner: string;
name: string;
}
export interface ProviderConformanceWorld {
connector: RepositoryProviderConnector;
companyId: string;
userId: string;
installationId: string;
accountName: string;
/** Replace the repository set visible to the installation. */
seed(repos: ConformanceSeedRepo[]): void;
/** Rename/transfer a repo, keeping its provider id stable. */
rename(providerRepositoryId: string, next: { owner: string; name: string }): void;
}
export type ConformanceWorldFactory = () => ProviderConformanceWorld;
function connectionFromMetadata(
world: ProviderConformanceWorld,
meta: { installationId: string; accountId: string | null; accountName: string | null; host: string; providerMetadata: Record<string, unknown> | null },
): RepositoryConnection {
const now = new Date();
return {
id: randomUUID(),
companyId: world.companyId,
provider: world.connector.provider,
host: meta.host,
installationId: meta.installationId,
accountId: meta.accountId,
accountName: meta.accountName,
status: "active",
syncStatus: "idle",
syncCursor: null,
syncError: null,
lastSyncedAt: null,
providerMetadata: meta.providerMetadata,
disconnectedAt: null,
createdAt: now,
updatedAt: now,
};
}
/**
* Registers the conformance `it` blocks. Call inside a `describe`. The factory
* must return a fresh, isolated world per invocation.
*/
export function runProviderConformance(makeWorld: ConformanceWorldFactory) {
async function install(world: ProviderConformanceWorld): Promise<RepositoryConnection> {
const begin = await world.connector.beginInstallation({ companyId: world.companyId, userId: world.userId });
expect(begin.installUrl).toContain("state=");
expect(begin.expiresAt.getTime()).toBeGreaterThan(Date.now());
const meta = await world.connector.completeInstallation({
state: begin.state,
installationId: world.installationId,
companyId: world.companyId,
});
expect(meta.installationId).toBe(world.installationId);
expect(meta.accountName).toBe(world.accountName);
return connectionFromMetadata(world, meta);
}
it("completes installation with a valid signed state and rejects tampered/mismatched state", async () => {
const world = makeWorld();
world.seed([{ providerRepositoryId: "1", owner: "acme", name: "alpha" }]);
const begin = await world.connector.beginInstallation({ companyId: world.companyId, userId: world.userId });
await expect(world.connector.completeInstallation({
state: `${begin.state}tampered`,
installationId: world.installationId,
companyId: world.companyId,
})).rejects.toMatchObject({ status: 422 });
await expect(world.connector.completeInstallation({
state: begin.state,
installationId: world.installationId,
companyId: "00000000-0000-0000-0000-000000000000",
})).rejects.toMatchObject({ status: expect.any(Number) });
const meta = await world.connector.completeInstallation({
state: begin.state,
installationId: world.installationId,
companyId: world.companyId,
});
expect(meta.companyId).toBe(world.companyId);
expect(meta.userId).toBe(world.userId);
});
it("discovers repositories with pagination and search", async () => {
const world = makeWorld();
world.seed([
{ providerRepositoryId: "1", owner: "acme", name: "alpha" },
{ providerRepositoryId: "2", owner: "acme", name: "beta" },
{ providerRepositoryId: "3", owner: "acme", name: "gamma" },
]);
const connection = await install(world);
const page1 = await world.connector.discover({ connection, pageSize: 2 });
expect(page1.items).toHaveLength(2);
expect(page1.nextCursor).not.toBeNull();
expect(page1.total).toBe(3);
const page2 = await world.connector.discover({ connection, pageSize: 2, cursor: page1.nextCursor });
expect(page2.items).toHaveLength(1);
expect(page2.nextCursor).toBeNull();
const allIds = [...page1.items, ...page2.items].map((r) => r.providerRepositoryId).sort();
expect(allIds).toEqual(["1", "2", "3"]);
const searched = await world.connector.discover({ connection, query: "beta", pageSize: 10 });
expect(searched.items.map((r) => r.name)).toEqual(["beta"]);
});
it("maps discovery items to importable snapshots with stable provider ids", async () => {
const world = makeWorld();
world.seed([{ providerRepositoryId: "77", owner: "acme", name: "delta" }]);
const connection = await install(world);
const discovered = (await world.connector.discover({ connection, pageSize: 10 })).items;
expect(discovered[0]).toMatchObject({ providerRepositoryId: "77", owner: "acme", name: "delta" });
expect(discovered[0]!.cloneUrl).toContain("acme/delta");
});
it("refreshes metadata for a known repository and returns null for unknown ids", async () => {
const world = makeWorld();
world.seed([{ providerRepositoryId: "5", owner: "acme", name: "epsilon" }]);
const connection = await install(world);
const refreshed = await world.connector.refreshMetadata({ connection, providerRepositoryId: "5" });
expect(refreshed).toMatchObject({ providerRepositoryId: "5" });
expect(await world.connector.refreshMetadata({ connection, providerRepositoryId: "does-not-exist" })).toBeNull();
});
it("converges idempotently across sync and reflects rename/transfer by stable id", async () => {
const world = makeWorld();
world.seed([
{ providerRepositoryId: "10", owner: "acme", name: "one" },
{ providerRepositoryId: "11", owner: "acme", name: "two" },
]);
const connection = await install(world);
const first = await world.connector.sync({ connection, cursor: null });
expect(first.repositories.map((r) => r.providerRepositoryId).sort()).toEqual(["10", "11"]);
const second = await world.connector.sync({ connection, cursor: null });
expect(second.repositories.map((r) => r.providerRepositoryId).sort()).toEqual(["10", "11"]);
world.rename("10", { owner: "acme-new", name: "one-renamed" });
const afterRename = await world.connector.sync({ connection, cursor: null });
const renamed = afterRename.repositories.find((r) => r.providerRepositoryId === "10");
expect(renamed?.cloneUrl).toContain("acme-new/one-renamed");
});
it("resolves a short-lived clone credential without leaking secrets into the audit record", async () => {
const world = makeWorld();
world.seed([{ providerRepositoryId: "9", owner: "acme", name: "zeta" }]);
const connection = await install(world);
const result = await world.connector.resolveCloneCredential({
connection,
repository: {
id: "repo-uuid",
providerRepositoryId: "9",
owner: "acme",
name: "zeta",
host: connection.host,
cloneUrl: `https://${connection.host}/acme/zeta.git`,
},
});
expect(result.token).toBeTruthy();
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
expect(result.authenticatedCloneUrl).toContain(result.token);
// The audit record must never carry the token or the authenticated URL.
const auditJson = JSON.stringify(result.audit);
expect(auditJson).not.toContain(result.token);
expect(result.audit).toMatchObject({ repositoryId: "repo-uuid", connectionId: connection.id });
});
}
+2 -2
View File
@@ -452,7 +452,7 @@ describeEmbeddedPostgres("repository services and routes", () => {
provider: "test_provider",
host: "git.example.com",
}).returning();
const providerRepository = await repositoryService(db).upsertProviderRepository(
const { repository: providerRepository } = await repositoryService(db).upsertProviderRepository(
seeded.companyA.id,
connection!.id,
"test_provider",
@@ -567,7 +567,7 @@ describeEmbeddedPostgres("repository services and routes", () => {
expect(created.created).toBe(true);
expect(duplicate.created).toBe(false);
const providerRepository = await repositoryService(db).upsertProviderRepository(
const { repository: providerRepository } = await repositoryService(db).upsertProviderRepository(
seeded.companyA.id,
created.connection.id,
provider.provider,
@@ -109,7 +109,7 @@ describeEmbeddedPostgres("repository connection flow (GitHub-style provider)", (
it("runs install -> callback -> discover -> import end to end", async () => {
const company = await seedCompany();
const { installationId } = registerFakeProvider();
const { provider, installationId } = registerFakeProvider();
const app = createApp(db, boardActor(company.id));
const install = await request(app)
@@ -157,8 +157,45 @@ describeEmbeddedPostgres("repository connection flow (GitHub-style provider)", (
const reimport = await request(app)
.post(`/api/repository-connections/${connectionId}/import`)
.send({ providerRepositoryIds: ["1"] });
expect(reimport.status).toBe(200);
expect(reimport.body.imported).toBe(0);
expect(reimport.body.repositories[0].id).toBe(importedRepositoryId);
expect(await db.select().from(repositories)).toHaveLength(1);
// A second installation sees the provider identity as already imported.
// Importing it directly remains idempotent and must not move catalog
// ownership to the new connection or over-report newly created rows.
const secondInstallationId = "inst-2";
provider.setInstallation({
installationId: secondInstallationId,
accountId: "acct-2",
accountName: "acme-secondary",
repositories: [{ providerRepositoryId: "1", owner: "acme", name: "alpha" }],
});
const secondInstall = await request(app)
.post(`/api/companies/${company.id}/repository-connections/fake/install`)
.send({});
const secondCallback = await request(app)
.post(`/api/companies/${company.id}/repository-connections/fake/callback`)
.send({ state: secondInstall.body.state, installationId: secondInstallationId });
const secondConnectionId = secondCallback.body.connection.id as string;
const secondDiscovery = await request(app)
.get(`/api/repository-connections/${secondConnectionId}/discover?query=alpha`);
expect(secondDiscovery.body.items[0]).toMatchObject({
imported: true,
importedRepositoryId,
});
const secondImport = await request(app)
.post(`/api/repository-connections/${secondConnectionId}/import`)
.send({ providerRepositoryIds: ["1"] });
expect(secondImport.status).toBe(200);
expect(secondImport.body.imported).toBe(0);
expect(secondImport.body.repositories[0].id).toBe(importedRepositoryId);
expect(await db.select().from(repositories)).toEqual([
expect.objectContaining({ id: importedRepositoryId, connectionId }),
]);
});
it("rejects a tampered callback state", async () => {
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import type { RepositoryConnection } from "@paperclipai/shared";
import type { HostToWorkerMethodName } from "@paperclipai/plugin-sdk";
import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk";
import { FakeRepositoryProvider } from "../services/repository-providers/index.js";
import { FakeRepositoryProvider } from "../services/repository-providers/fake-provider.js";
import {
createPluginRepositoryProviderConnector,
REQUIRED_REPOSITORY_PROVIDER_METHODS,
@@ -11,7 +11,7 @@ import {
import {
runProviderConformance,
type ProviderConformanceWorld,
} from "./helpers/repository-provider-conformance.js";
} from "./helpers/fake-github-transport.js";
/**
* Parity check for the extension seam: the exact conformance suite the
@@ -7,16 +7,20 @@ import { pluginCapabilityValidator } from "../services/plugin-capability-validat
import { repositoryProviderRegistry } from "../services/repository-connections.js";
import {
authorizedRepositoryProviderDeclarations,
createPluginRepositoryProviderConnector,
createPluginRepositoryProviderRegistrar,
getRepositoryProviderConnector,
listAvailableRepositoryProviders,
markCloneCredentialNoStore,
pluginRepositoryProviderOwnerKey,
REQUIRED_REPOSITORY_PROVIDER_METHODS,
type RepositoryProviderPluginRecord,
type RepositoryProviderPluginSource,
} from "../services/repository-providers/index.js";
} from "../services/repository-providers/plugin-registrar.js";
import {
createPluginRepositoryProviderConnector,
REQUIRED_REPOSITORY_PROVIDER_METHODS,
} from "../services/repository-providers/plugin-connector.js";
import { markCloneCredentialNoStore } from "../services/repository-providers/connector-guard.js";
import {
getRepositoryProviderConnector,
listAvailableRepositoryProviders,
} from "../services/repository-providers/registry.js";
/**
* Regression coverage for the trusted-extension repository provider seam
@@ -1,19 +1,18 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import {
createGitHubProvider,
FakeRepositoryProvider,
signConnectionState,
verifyConnectionState,
} from "../services/repository-providers/index.js";
} from "../services/repository-providers/connection-state.js";
import { FakeRepositoryProvider } from "../services/repository-providers/fake-provider.js";
import { createGitHubProvider } from "../services/repository-providers/github-provider.js";
import { createGitHubAppTransport } from "../services/repository-providers/github-transport.js";
import {
FakeGitHubTransport,
makeGitHubRepository,
} from "./helpers/fake-github-transport.js";
import {
runProviderConformance,
type ProviderConformanceWorld,
} from "./helpers/repository-provider-conformance.js";
} from "./helpers/fake-github-transport.js";
describe("connection state signing", () => {
const secret = "state-secret";
@@ -216,3 +215,79 @@ describe("github.com provider specifics", () => {
})).rejects.toThrow(/suspended/);
});
});
const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048 })
.privateKey.export({ type: "pkcs8", format: "pem" })
.toString();
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("GitHub App transport", () => {
it("searches the complete installation catalog before paginating matches", async () => {
const repositories = Array.from({ length: 205 }, (_, index) => makeGitHubRepository({
id: index + 1,
owner: index === 100 || index === 101 || index === 200 ? "matching-owner" : "other-owner",
name: `repo-${index + 1}`,
}));
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url);
if (url.pathname.endsWith("/access_tokens")) {
return jsonResponse({ token: "ghs_test", expires_at: "2099-01-01T00:00:00.000Z" });
}
const page = Number(url.searchParams.get("page"));
const perPage = Number(url.searchParams.get("per_page"));
const start = (page - 1) * perPage;
return jsonResponse({ total_count: repositories.length, repositories: repositories.slice(start, start + perPage) });
});
const transport = createGitHubAppTransport({ appId: "1", privateKey, fetchImpl });
await expect(transport.listRepositories({
installationId: "installation-1",
page: 1,
perPage: 2,
query: "matching-owner",
})).resolves.toMatchObject({
totalCount: 3,
items: [{ id: 101 }, { id: 102 }],
});
await expect(transport.listRepositories({
installationId: "installation-1",
page: 2,
perPage: 2,
query: "matching-owner",
})).resolves.toMatchObject({
totalCount: 3,
items: [{ id: 201 }],
});
expect(fetchImpl.mock.calls.some(([input]) => String(input).includes("per_page=100&page=3"))).toBe(true);
});
it("does not expose provider request paths in operator-facing failures", async () => {
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input : input.url);
if (url.pathname.endsWith("/access_tokens")) {
return jsonResponse({ token: "ghs_test", expires_at: "2099-01-01T00:00:00.000Z" });
}
return jsonResponse({ message: "unavailable" }, 503);
});
const transport = createGitHubAppTransport({ appId: "1", privateKey, fetchImpl });
await expect(transport.listRepositories({
installationId: "sensitive-installation",
page: 1,
perPage: 20,
query: null,
})).rejects.toThrow("GitHub repository provider request failed with status 503");
await expect(transport.listRepositories({
installationId: "sensitive-installation",
page: 1,
perPage: 20,
query: null,
})).rejects.not.toThrow("sensitive-installation");
});
});
+1 -1
View File
@@ -31,7 +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 { registerConfiguredRepositoryProviders } from "./services/repository-providers/registry.js";
import { createPluginRepositoryProviderRegistrar } from "./services/repository-providers/plugin-registrar.js";
import { issueRoutes } from "./routes/issues.js";
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
+10 -7
View File
@@ -424,7 +424,8 @@ export function repositoryRoutes(db: Db) {
});
const imported = await repositories.findImportedProviderRepositories(
connection.companyId,
connectionId,
connection.provider,
connection.host,
page.items.map((item) => item.providerRepositoryId),
);
const importedById = new Map(imported.map((repo) => [repo.providerRepositoryId, repo]));
@@ -468,7 +469,7 @@ export function repositoryRoutes(db: Db) {
if (connection.status === "disconnected") throw unprocessable("Disconnected repository connection cannot import repositories");
const providerRepositoryIds: string[] = req.body.providerRepositoryIds;
const importedRepositories = [];
const importResults = [];
const skipped: string[] = [];
for (const providerRepositoryId of providerRepositoryIds) {
const snapshot = await connector.refreshMetadata({ connection, providerRepositoryId });
@@ -476,12 +477,14 @@ export function repositoryRoutes(db: Db) {
skipped.push(providerRepositoryId);
continue;
}
importedRepositories.push(
importResults.push(
await repositories.upsertProviderRepository(connection.companyId, connectionId, connection.provider, snapshot),
);
}
const importedRepositories = importResults.map((result) => result.repository);
const importedCount = importResults.filter((result) => result.created).length;
const { attribution } = activityActor(req);
if (importedRepositories.length > 0) {
if (importedCount > 0) {
await logActivity(db, {
companyId: connection.companyId,
...attribution,
@@ -490,14 +493,14 @@ export function repositoryRoutes(db: Db) {
entityId: connectionId,
details: {
provider: connection.provider,
importedCount: importedRepositories.length,
importedCount,
skippedCount: skipped.length,
},
});
}
res
.status(importedRepositories.length > 0 ? 201 : 200)
.json({ repositories: importedRepositories, imported: importedRepositories.length, skipped });
.status(importedCount > 0 ? 201 : 200)
.json({ repositories: importedRepositories, imported: importedCount, skipped });
},
);
+72 -46
View File
@@ -164,7 +164,8 @@ export function repositoryService(db: Db) {
findImportedProviderRepositories: async (
companyId: string,
connectionId: string,
provider: string,
host: string,
providerRepositoryIds: string[],
): Promise<Repository[]> => {
if (providerRepositoryIds.length === 0) return [];
@@ -173,7 +174,8 @@ export function repositoryService(db: Db) {
.from(repositories)
.where(and(
eq(repositories.companyId, companyId),
eq(repositories.connectionId, connectionId),
eq(repositories.provider, provider),
eq(repositories.host, host),
inArray(repositories.providerRepositoryId, providerRepositoryIds),
));
return rows.map(toRepository);
@@ -311,59 +313,83 @@ export function repositoryService(db: Db) {
});
const webUrl = input.webUrl ? normalizeRepositoryWebUrl(input.webUrl) : normalized.webUrl;
const now = new Date();
const row = await db.transaction(async (tx) => {
const updated = await tx
return db.transaction(async (tx) => {
const values = {
companyId,
connectionId,
provider,
providerRepositoryId: input.providerRepositoryId,
identityKey,
host: normalized.host,
owner: normalized.owner,
name: normalized.name,
cloneUrl: normalized.cloneUrl,
webUrl,
defaultBranch: input.defaultBranch ?? null,
visibility: input.visibility ?? "unknown",
state: input.state ?? "active",
unavailableReason: input.unavailableReason ?? null,
providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata),
updatedAt: now,
} satisfies typeof repositories.$inferInsert;
const updateValues = {
host: normalized.host,
owner: normalized.owner,
name: normalized.name,
cloneUrl: normalized.cloneUrl,
webUrl,
defaultBranch: input.defaultBranch ?? null,
visibility: input.visibility ?? "unknown",
state: sql`CASE
WHEN ${repositories.state} = 'archived' THEN ${repositories.state}
ELSE ${input.state ?? "active"}
END`,
unavailableReason: input.unavailableReason ?? null,
providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata),
updatedAt: now,
};
let existing = await tx
.select()
.from(repositories)
.where(and(eq(repositories.companyId, companyId), eq(repositories.identityKey, identityKey)))
.then((rows) => rows[0] ?? null);
if (existing) {
if (existing.connectionId === connectionId) {
existing = await tx
.update(repositories)
.set(updateValues)
.where(and(eq(repositories.companyId, companyId), eq(repositories.id, existing.id)))
.returning()
.then((rows) => rows[0] ?? existing);
}
return { repository: toRepository(existing), created: false };
}
const inserted = await tx
.insert(repositories)
.values({
companyId,
connectionId,
provider,
providerRepositoryId: input.providerRepositoryId,
identityKey,
host: normalized.host,
owner: normalized.owner,
name: normalized.name,
cloneUrl: normalized.cloneUrl,
webUrl,
defaultBranch: input.defaultBranch ?? null,
visibility: input.visibility ?? "unknown",
state: input.state ?? "active",
unavailableReason: input.unavailableReason ?? null,
providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata),
updatedAt: now,
})
.onConflictDoUpdate({
target: [repositories.companyId, repositories.identityKey],
set: {
connectionId,
host: normalized.host,
owner: normalized.owner,
name: normalized.name,
cloneUrl: normalized.cloneUrl,
webUrl,
defaultBranch: input.defaultBranch ?? null,
visibility: input.visibility ?? "unknown",
state: sql`CASE
WHEN ${repositories.state} = 'archived' THEN ${repositories.state}
ELSE ${input.state ?? "active"}
END`,
unavailableReason: input.unavailableReason ?? null,
providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata),
updatedAt: now,
},
})
.values(values)
.onConflictDoNothing({ target: [repositories.companyId, repositories.identityKey] })
.returning()
.then((rows) => rows[0]!);
.then((rows) => rows[0] ?? null);
if (!inserted) {
existing = await tx
.select()
.from(repositories)
.where(and(eq(repositories.companyId, companyId), eq(repositories.identityKey, identityKey)))
.then((rows) => rows[0] ?? null);
if (!existing) throw conflict("Repository import conflicted with another request");
return { repository: toRepository(existing), created: false };
}
await tx
.update(projectWorkspaces)
.set({ repoUrl: normalized.cloneUrl, updatedAt: now })
.where(and(
eq(projectWorkspaces.companyId, companyId),
eq(projectWorkspaces.repositoryId, updated.id),
eq(projectWorkspaces.repositoryId, inserted.id),
));
return updated;
return { repository: toRepository(inserted), created: true };
});
return toRepository(row!);
},
markConnectionUnavailable: async (companyId: string, connectionId: string, reason: string) => {
@@ -357,7 +357,7 @@ export function repositoryConnectionService(
const snapshot = snapshotsById.get(providerRepositoryId);
if (!snapshot) continue;
syncedRepositories.push(
await transactionalRepositories.upsertProviderRepository(companyId, id, row.provider, snapshot),
(await transactionalRepositories.upsertProviderRepository(companyId, id, row.provider, snapshot)).repository,
);
}
const unavailableIds = [...importedIds].filter((providerRepositoryId) => !snapshotsById.has(providerRepositoryId));
@@ -1,53 +0,0 @@
import { repositoryProviderRegistry } from "../repository-connections.js";
import { guardRepositoryProviderConnector } from "./connector-guard.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(
// Core providers go through the same hardening wrapper as extensions:
// one contract, one place where provider output is validated.
guardRepositoryProviderConnector(provider, { provider: provider.provider, host: provider.host }),
{
host: provider.host,
descriptor: {
displayName: "GitHub",
source: "core",
supportsDiscovery: true,
supportsCloneCredentials: true,
},
},
));
}
return unregister;
}
@@ -1,10 +0,0 @@
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 "./connector-guard.js";
export * from "./plugin-connector.js";
export * from "./plugin-registrar.js";
export * from "./bootstrap.js";
@@ -4,6 +4,9 @@ import {
type RepositoryProviderLookup,
} from "../repository-connections.js";
import type { RepositoryProviderConnector } from "./provider-contract.js";
import { guardRepositoryProviderConnector } from "./connector-guard.js";
import { createGitHubProvider } from "./github-provider.js";
import { createGitHubAppTransport } from "./github-transport.js";
/**
* Narrows a registered Foundation adapter to the full connection/discovery
@@ -69,3 +72,44 @@ export function listAvailableRepositoryProviders(
}))
.sort((a, b) => (a.provider === b.provider ? a.host.localeCompare(b.host) : a.provider.localeCompare(b.provider)));
}
export interface RegisterRepositoryProvidersOptions {
env?: NodeJS.ProcessEnv;
}
/** Registers core repository providers configured through server environment. */
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(
guardRepositoryProviderConnector(provider, { provider: provider.provider, host: provider.host }),
{
host: provider.host,
descriptor: {
displayName: "GitHub",
source: "core",
supportsDiscovery: true,
supportsCloneCredentials: true,
},
},
));
}
return unregister;
}