feat(repositories): GitHub.com provider + fake-provider conformance harness

Extend the Foundation provider contract with installation lifecycle,
searchable paginated discovery, metadata refresh, and short-lived clone
credential resolution. Add signed expiring connection state (HMAC, bound
to company/user), an in-memory fake provider, a reusable conformance
harness both providers pass, and the GitHub.com GitHub App provider with
an injectable transport (App JWT / installation-token exchange isolated).

- Idempotent sync + rename/transfer converge via stable providerRepositoryId
- Archived provider repos import as unavailable
- Clone credentials are transient; audit records carry no secrets

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent 0c9d8e64ff
commit 823824907c
8 files changed
+1286

No files matched your search

@@ -0,0 +1,108 @@
import type {
GitHubAppTransport,
GitHubInstallation,
GitHubInstallationToken,
GitHubRepository,
} from "../../services/repository-providers/github-provider.js";
/**
* Controllable in-memory GitHub App transport for provider tests. It stands in
* for GitHub's HTTP API + App JWT/installation-token exchange so the GitHub.com
* provider's own logic (state, discovery, pagination, snapshot mapping,
* rename/transfer convergence, credential resolution) can be exercised without
* a live GitHub. No real app private key is involved.
*/
export class FakeGitHubTransport implements GitHubAppTransport {
readonly host = "github.com";
readonly apiBaseUrl = "https://api.github.com";
private readonly installations = new Map<string, GitHubInstallation>();
private readonly repositories = new Map<string, GitHubRepository[]>();
private tokenCounter = 0;
private clock: () => Date;
tokenTtlMs = 60 * 60 * 1000;
/** Set to force token minting to fail (e.g. revoked installation). */
failTokenMint: Error | null = null;
constructor(clock: () => Date = () => new Date()) {
this.clock = clock;
}
setInstallation(installation: GitHubInstallation, repositories: GitHubRepository[]) {
this.installations.set(String(installation.id), installation);
this.repositories.set(String(installation.id), [...repositories]);
}
setRepositories(installationId: string, repositories: GitHubRepository[]) {
this.repositories.set(installationId, [...repositories]);
}
rename(installationId: string, providerRepositoryId: string, next: { owner: string; name: string }) {
const repos = this.repositories.get(installationId) ?? [];
const repo = repos.find((r) => String(r.id) === providerRepositoryId);
if (!repo) throw new Error(`fake github repo ${providerRepositoryId} not found`);
repo.owner = { ...repo.owner, login: next.owner };
repo.name = next.name;
repo.full_name = `${next.owner}/${next.name}`;
repo.clone_url = `https://github.com/${next.owner}/${next.name}.git`;
repo.html_url = `https://github.com/${next.owner}/${next.name}`;
}
async getInstallation(installationId: string): Promise<GitHubInstallation | null> {
return this.installations.get(installationId) ?? null;
}
async createInstallationAccessToken(installationId: string): Promise<GitHubInstallationToken> {
if (this.failTokenMint) throw this.failTokenMint;
if (!this.installations.has(installationId)) throw new Error("installation not found");
this.tokenCounter += 1;
return {
token: `ghs_faketoken${this.tokenCounter}`,
expiresAt: new Date(this.clock().getTime() + this.tokenTtlMs),
};
}
async listRepositories(input: {
installationId: string;
page: number;
perPage: number;
query?: string | null;
}): Promise<{ totalCount: number | null; items: GitHubRepository[] }> {
const repos = this.repositories.get(input.installationId) ?? [];
const query = input.query?.trim().toLowerCase();
const matches = query
? repos.filter((r) => r.full_name.toLowerCase().includes(query))
: repos;
const start = (input.page - 1) * input.perPage;
return {
totalCount: matches.length,
items: matches.slice(start, start + input.perPage),
};
}
async getRepository(input: {
installationId: string;
providerRepositoryId: string;
}): Promise<GitHubRepository | null> {
const repos = this.repositories.get(input.installationId) ?? [];
return repos.find((r) => String(r.id) === input.providerRepositoryId) ?? null;
}
}
export function makeGitHubRepository(overrides: Partial<GitHubRepository> & { id: number | string; owner: string; name: string }): GitHubRepository {
const owner = overrides.owner;
const name = overrides.name;
return {
id: overrides.id,
name,
full_name: `${owner}/${name}`,
owner: { login: owner, id: overrides.id, type: "Organization" },
clone_url: `https://github.com/${owner}/${name}.git`,
html_url: `https://github.com/${owner}/${name}`,
default_branch: "main",
private: true,
visibility: "private",
archived: false,
fork: false,
...overrides,
};
}
@@ -0,0 +1,191 @@
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 });
});
}
@@ -0,0 +1,218 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
createGitHubProvider,
FakeRepositoryProvider,
signConnectionState,
verifyConnectionState,
} from "../services/repository-providers/index.js";
import {
FakeGitHubTransport,
makeGitHubRepository,
} from "./helpers/fake-github-transport.js";
import {
runProviderConformance,
type ProviderConformanceWorld,
} from "./helpers/repository-provider-conformance.js";
describe("connection state signing", () => {
const secret = "state-secret";
const base = {
companyId: randomUUID(),
userId: randomUUID(),
provider: "github",
secret,
ttlMs: 1000,
nonce: "nonce-1",
};
it("round-trips a signed state and binds company/user", () => {
const now = new Date("2026-01-01T00:00:00Z");
const { state } = signConnectionState({ ...base, now });
const payload = verifyConnectionState({ state, provider: "github", secret, now });
expect(payload.companyId).toBe(base.companyId);
expect(payload.userId).toBe(base.userId);
});
it("rejects expired state", () => {
const now = new Date("2026-01-01T00:00:00Z");
const { state } = signConnectionState({ ...base, ttlMs: 1000, now });
expect(() => verifyConnectionState({
state,
provider: "github",
secret,
now: new Date(now.getTime() + 2000),
})).toThrow(/expired/i);
});
it("rejects a tampered signature and wrong provider/secret", () => {
const { state } = signConnectionState({ ...base });
expect(() => verifyConnectionState({ state: `${state}x`, provider: "github", secret })).toThrow(/signature/i);
expect(() => verifyConnectionState({ state, provider: "github", secret: "other" })).toThrow(/signature/i);
expect(() => verifyConnectionState({ state, provider: "gitlab", secret })).toThrow(/different provider/i);
});
});
describe("fake provider conformance", () => {
runProviderConformance((): ProviderConformanceWorld => {
const provider = new FakeRepositoryProvider({ provider: "fake", host: "fake.example.com", pageSize: 2 });
const companyId = randomUUID();
const userId = randomUUID();
const installationId = "inst-fake-1";
const accountName = "acme";
const seedState = { repos: [] as Array<{ providerRepositoryId: string; owner: string; name: string }> };
const apply = () => provider.setInstallation({
installationId,
accountId: "acct-1",
accountName,
repositories: seedState.repos.map((r) => ({ ...r })),
});
apply();
return {
connector: provider.connector(),
companyId,
userId,
installationId,
accountName,
seed(repos) {
seedState.repos = repos.map((r) => ({ ...r }));
apply();
},
rename(providerRepositoryId, next) {
provider.rename(installationId, providerRepositoryId, next);
},
};
});
});
describe("github.com provider conformance", () => {
runProviderConformance((): ProviderConformanceWorld => {
const transport = new FakeGitHubTransport();
const provider = createGitHubProvider({
transport,
config: { appSlug: "paperclip", stateSecret: "gh-state-secret" },
});
const companyId = randomUUID();
const userId = randomUUID();
const installationId = "42";
const accountName = "acme";
const applyInstallation = (repos: Array<{ providerRepositoryId: string; owner: string; name: string }>) => {
transport.setInstallation(
{ id: installationId, account: { id: 1000, login: accountName, type: "Organization" }, app_slug: "paperclip" },
repos.map((r) => makeGitHubRepository({ id: r.providerRepositoryId, owner: r.owner, name: r.name })),
);
};
applyInstallation([]);
return {
connector: provider,
companyId,
userId,
installationId,
accountName,
seed(repos) {
applyInstallation(repos);
},
rename(providerRepositoryId, next) {
transport.rename(installationId, providerRepositoryId, next);
},
};
});
});
describe("github.com provider specifics", () => {
function setup() {
const transport = new FakeGitHubTransport();
const provider = createGitHubProvider({
transport,
config: { appSlug: "paperclip", stateSecret: "gh-state-secret" },
});
const companyId = randomUUID();
const userId = randomUUID();
return { transport, provider, companyId, userId };
}
async function connect(ctx: ReturnType<typeof setup>, installationId: string, repos = [] as ReturnType<typeof makeGitHubRepository>[]) {
ctx.transport.setInstallation(
{ id: installationId, account: { id: 7, login: "acme", type: "Organization" }, app_slug: "paperclip", repository_selection: "selected" },
repos,
);
const begin = await ctx.provider.beginInstallation({ companyId: ctx.companyId, userId: ctx.userId });
const meta = await ctx.provider.completeInstallation({ state: begin.state, installationId, companyId: ctx.companyId });
const now = new Date();
const connection = {
id: randomUUID(),
companyId: ctx.companyId,
provider: "github",
host: meta.host,
installationId: meta.installationId,
accountId: meta.accountId,
accountName: meta.accountName,
status: "active" as const,
syncStatus: "idle" as const,
syncCursor: null,
syncError: null,
lastSyncedAt: null,
providerMetadata: meta.providerMetadata,
disconnectedAt: null,
createdAt: now,
updatedAt: now,
};
return { meta, connection };
}
it("builds an install URL bound to the app slug and a signed state", async () => {
const ctx = setup();
const begin = await ctx.provider.beginInstallation({ companyId: ctx.companyId, userId: ctx.userId });
expect(begin.installUrl).toContain("github.com/apps/paperclip/installations/new");
expect(begin.installUrl).toContain("state=");
});
it("surfaces installation account metadata without persisting secrets", async () => {
const ctx = setup();
const { meta } = await connect(ctx, "100");
expect(meta).toMatchObject({ installationId: "100", accountName: "acme", accountId: "7", host: "github.com" });
expect(JSON.stringify(meta.providerMetadata)).not.toMatch(/token|secret|private/i);
});
it("archived provider repositories import as unavailable", async () => {
const ctx = setup();
const { connection } = await connect(ctx, "101", [
makeGitHubRepository({ id: 1, owner: "acme", name: "live" }),
makeGitHubRepository({ id: 2, owner: "acme", name: "old", archived: true }),
]);
const synced = await ctx.provider.sync({ connection, cursor: null });
const old = synced.repositories.find((r) => r.providerRepositoryId === "2");
expect(old?.state).toBe("unavailable");
expect(old?.unavailableReason).toMatch(/archived/i);
});
it("paginates a full sync across multiple pages", async () => {
const ctx = setup();
const repos = Array.from({ length: 150 }, (_, i) => makeGitHubRepository({ id: i + 1, owner: "acme", name: `repo-${i + 1}` }));
const { connection } = await connect(ctx, "102", repos);
const synced = await ctx.provider.sync({ connection, cursor: null });
expect(synced.repositories).toHaveLength(150);
});
it("resolves a short-lived installation clone credential and redacts it from audit", async () => {
const ctx = setup();
const { connection } = await connect(ctx, "103", [makeGitHubRepository({ id: 5, owner: "acme", name: "secure" })]);
const result = await ctx.provider.resolveCloneCredential({
connection,
repository: { id: "r-1", providerRepositoryId: "5", owner: "acme", name: "secure", host: "github.com", cloneUrl: "https://github.com/acme/secure.git" },
});
expect(result.token).toMatch(/^ghs_/);
expect(result.authenticatedCloneUrl).toBe(`https://x-access-token:${result.token}@github.com/acme/secure.git`);
expect(JSON.stringify(result.audit)).not.toContain(result.token);
});
it("propagates provider token-mint failures for revoked installations", async () => {
const ctx = setup();
const { connection } = await connect(ctx, "104", [makeGitHubRepository({ id: 6, owner: "acme", name: "revoked" })]);
ctx.transport.failTokenMint = new Error("installation suspended");
await expect(ctx.provider.resolveCloneCredential({
connection,
repository: { id: "r-2", providerRepositoryId: "6", owner: "acme", name: "revoked", host: "github.com", cloneUrl: "https://github.com/acme/revoked.git" },
})).rejects.toThrow(/suspended/);
});
});
@@ -0,0 +1,95 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { unprocessable } from "../../errors.js";
/**
* Signed, expiring connection state for provider install/callback flows.
*
* The state is an HMAC-SHA256 signed, base64url-encoded payload bound to the
* company and user that initiated the connection. It carries an expiry and a
* nonce so a callback cannot be replayed after it lapses or be forged without
* the app's state secret. The secret never leaves the server and is never
* persisted on any connection/repository row.
*/
export interface ConnectionStatePayload {
companyId: string;
userId: string;
provider: string;
/** Optional app-relative return path. */
redirectPath?: string | null;
/** Random per-request nonce. */
nonce: string;
/** Issued-at epoch ms. */
iat: number;
/** Expiry epoch ms. */
exp: number;
}
export interface SignConnectionStateInput {
companyId: string;
userId: string;
provider: string;
redirectPath?: string | null;
secret: string;
ttlMs: number;
nonce: string;
now?: Date;
}
function base64url(input: Buffer | string): string {
return Buffer.from(input).toString("base64url");
}
function sign(secret: string, body: string): string {
return createHmac("sha256", secret).update(body).digest("base64url");
}
export function signConnectionState(input: SignConnectionStateInput): { state: string; payload: ConnectionStatePayload } {
if (!input.secret) throw unprocessable("Provider state secret is not configured");
const iat = (input.now ?? new Date()).getTime();
const payload: ConnectionStatePayload = {
companyId: input.companyId,
userId: input.userId,
provider: input.provider,
redirectPath: input.redirectPath ?? null,
nonce: input.nonce,
iat,
exp: iat + Math.max(1, input.ttlMs),
};
const body = base64url(JSON.stringify(payload));
const signature = sign(input.secret, body);
return { state: `${body}.${signature}`, payload };
}
export interface VerifyConnectionStateInput {
state: string;
provider: string;
secret: string;
now?: Date;
}
export function verifyConnectionState(input: VerifyConnectionStateInput): ConnectionStatePayload {
if (!input.secret) throw unprocessable("Provider state secret is not configured");
const parts = input.state.split(".");
if (parts.length !== 2 || !parts[0] || !parts[1]) throw unprocessable("Connection state is malformed");
const [body, signature] = parts;
const expected = sign(input.secret, body);
const expectedBuf = Buffer.from(expected);
const actualBuf = Buffer.from(signature);
if (expectedBuf.length !== actualBuf.length || !timingSafeEqual(expectedBuf, actualBuf)) {
throw unprocessable("Connection state signature is invalid");
}
let payload: ConnectionStatePayload;
try {
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as ConnectionStatePayload;
} catch {
throw unprocessable("Connection state payload is invalid");
}
if (payload.provider !== input.provider) throw unprocessable("Connection state is for a different provider");
const now = (input.now ?? new Date()).getTime();
if (typeof payload.exp !== "number" || now > payload.exp) throw unprocessable("Connection state has expired");
if (!payload.companyId || !payload.userId) throw unprocessable("Connection state is missing its subject binding");
return payload;
}
@@ -0,0 +1,229 @@
import { randomUUID } from "node:crypto";
import type { RepositoryConnection, RepositoryVisibility } from "@paperclipai/shared";
import { conflict, unprocessable } from "../../errors.js";
import type { ProviderRepositorySnapshot } from "../repositories.js";
import { normalizeRepositoryLocator } from "../repository-normalization.js";
import { signConnectionState, verifyConnectionState } from "./connection-state.js";
import type {
BeginInstallationInput,
BeginInstallationResult,
CompleteInstallationInput,
CompleteInstallationResult,
DiscoverInput,
DiscoverResult,
RefreshMetadataInput,
RepositoryProviderConnector,
ResolveCloneCredentialInput,
ResolveCloneCredentialResult,
} from "./provider-contract.js";
/**
* In-memory reference provider used by the conformance harness. It implements
* the full {@link RepositoryProviderConnector} contract with a controllable
* backing world so tests can exercise installation lifecycle, discovery,
* pagination, metadata refresh, rename/transfer, revocation, and short-lived
* clone credential resolution against a known-good implementation.
*/
export interface FakeRepository {
providerRepositoryId: string;
owner: string;
name: string;
defaultBranch?: string | null;
visibility?: RepositoryVisibility;
archived?: boolean;
}
export interface FakeInstallation {
installationId: string;
accountId: string;
accountName: string;
repositories: FakeRepository[];
}
export interface FakeProviderOptions {
provider?: string;
host?: string;
stateSecret?: string;
pageSize?: number;
tokenTtlMs?: number;
clock?: () => Date;
}
export class FakeRepositoryProvider {
readonly provider: string;
readonly host: string;
private readonly stateSecret: string;
private readonly defaultPageSize: number;
private readonly tokenTtlMs: number;
private readonly clock: () => Date;
private readonly installations = new Map<string, FakeInstallation>();
private tokenCounter = 0;
constructor(options: FakeProviderOptions = {}) {
this.provider = (options.provider ?? "fake").toLowerCase();
this.host = (options.host ?? "fake.example.com").toLowerCase();
this.stateSecret = options.stateSecret ?? "fake-state-secret";
this.defaultPageSize = options.pageSize ?? 2;
this.tokenTtlMs = options.tokenTtlMs ?? 60 * 60 * 1000;
this.clock = options.clock ?? (() => new Date());
}
/** Register/replace an installation's repository set. */
setInstallation(installation: FakeInstallation) {
this.installations.set(installation.installationId, {
...installation,
repositories: [...installation.repositories],
});
}
/** Rename/transfer a repository, keeping its provider id stable. */
rename(installationId: string, providerRepositoryId: string, next: { owner: string; name: string }) {
const installation = this.installations.get(installationId);
const repo = installation?.repositories.find((r) => r.providerRepositoryId === providerRepositoryId);
if (!repo) throw new Error(`fake repository ${providerRepositoryId} not found`);
repo.owner = next.owner;
repo.name = next.name;
}
private cloneUrl(repo: FakeRepository) {
return `https://${this.host}/${repo.owner}/${repo.name}.git`;
}
private webUrl(repo: FakeRepository) {
return `https://${this.host}/${repo.owner}/${repo.name}`;
}
private snapshot(repo: FakeRepository): ProviderRepositorySnapshot {
return {
providerRepositoryId: repo.providerRepositoryId,
cloneUrl: this.cloneUrl(repo),
webUrl: this.webUrl(repo),
defaultBranch: repo.defaultBranch ?? "main",
visibility: repo.visibility ?? "private",
state: repo.archived ? "unavailable" : "active",
unavailableReason: repo.archived ? "Repository is archived" : null,
providerMetadata: { owner: repo.owner, name: repo.name, archived: repo.archived ?? false },
};
}
private requireInstallation(connection: RepositoryConnection): FakeInstallation {
if (!connection.installationId) throw unprocessable("Connection is missing an installation id");
const installation = this.installations.get(connection.installationId);
if (!installation) throw unprocessable("Installation was not found");
return installation;
}
connector(): RepositoryProviderConnector {
const self = this;
return {
provider: self.provider,
host: self.host,
async beginInstallation(input: BeginInstallationInput): Promise<BeginInstallationResult> {
const ttlMs = 10 * 60 * 1000;
const { state, payload } = signConnectionState({
companyId: input.companyId,
userId: input.userId,
provider: self.provider,
redirectPath: input.redirectPath ?? null,
secret: self.stateSecret,
ttlMs,
nonce: randomUUID(),
now: self.clock(),
});
return {
installUrl: `https://${self.host}/install?state=${encodeURIComponent(state)}`,
state,
expiresAt: new Date(payload.exp),
};
},
async completeInstallation(input: CompleteInstallationInput): Promise<CompleteInstallationResult> {
const payload = verifyConnectionState({
state: input.state,
provider: self.provider,
secret: self.stateSecret,
now: self.clock(),
});
if (payload.companyId !== input.companyId) throw conflict("Connection state company mismatch");
const installation = self.installations.get(input.installationId);
if (!installation) throw unprocessable("Installation was not found");
return {
companyId: payload.companyId,
userId: payload.userId,
installationId: installation.installationId,
accountId: installation.accountId,
accountName: installation.accountName,
host: self.host,
providerMetadata: { account_login: installation.accountName },
};
},
async discover(input: DiscoverInput): Promise<DiscoverResult> {
const installation = self.requireInstallation(input.connection);
const perPage = input.pageSize ?? self.defaultPageSize;
const page = input.cursor ? Number.parseInt(input.cursor, 10) : 1;
if (!Number.isFinite(page) || page < 1) throw unprocessable("Discovery cursor is invalid");
const query = input.query?.trim().toLowerCase();
const matches = installation.repositories.filter((repo) =>
!query || `${repo.owner}/${repo.name}`.toLowerCase().includes(query),
);
const start = (page - 1) * perPage;
const slice = matches.slice(start, start + perPage);
return {
items: slice.map((repo) => ({
providerRepositoryId: repo.providerRepositoryId,
owner: repo.owner,
name: repo.name,
cloneUrl: self.cloneUrl(repo),
webUrl: self.webUrl(repo),
defaultBranch: repo.defaultBranch ?? "main",
visibility: repo.visibility ?? "private",
archived: repo.archived ?? false,
metadata: { owner: repo.owner, name: repo.name },
})),
nextCursor: start + perPage < matches.length ? String(page + 1) : null,
total: matches.length,
};
},
async refreshMetadata(input: RefreshMetadataInput): Promise<ProviderRepositorySnapshot | null> {
const installation = self.requireInstallation(input.connection);
const repo = installation.repositories.find((r) => r.providerRepositoryId === input.providerRepositoryId);
return repo ? self.snapshot(repo) : null;
},
async sync(input: { connection: RepositoryConnection; cursor: string | null }) {
const installation = self.requireInstallation(input.connection);
return { repositories: installation.repositories.map((repo) => self.snapshot(repo)), cursor: null };
},
async resolveCloneCredential(input: ResolveCloneCredentialInput): Promise<ResolveCloneCredentialResult> {
const installation = self.requireInstallation(input.connection);
self.tokenCounter += 1;
const token = `fake-token-${self.tokenCounter}`;
const expiresAt = new Date(self.clock().getTime() + self.tokenTtlMs);
return {
username: "x-access-token",
token,
authenticatedCloneUrl:
`https://x-access-token:${token}@${input.repository.host}/${input.repository.owner}/${input.repository.name}.git`,
expiresAt,
audit: {
provider: self.provider,
host: self.host,
connectionId: input.connection.id,
repositoryId: input.repository.id,
installationId: installation.installationId,
expiresAt,
},
};
},
async disconnect(): Promise<void> {
// No persisted token to revoke.
},
};
}
}
@@ -0,0 +1,294 @@
import { randomUUID } from "node:crypto";
import type { RepositoryConnection, RepositoryVisibility } from "@paperclipai/shared";
import { conflict, unprocessable } from "../../errors.js";
import type { ProviderRepositorySnapshot } from "../repositories.js";
import {
normalizeRepositoryLocator,
sanitizeRepositoryProviderMetadata,
} from "../repository-normalization.js";
import {
signConnectionState,
verifyConnectionState,
} from "./connection-state.js";
import type {
BeginInstallationInput,
BeginInstallationResult,
CompleteInstallationInput,
CompleteInstallationResult,
DiscoverInput,
DiscoverResult,
DiscoveredRepository,
RefreshMetadataInput,
RepositoryProviderConnector,
ResolveCloneCredentialInput,
ResolveCloneCredentialResult,
} from "./provider-contract.js";
export const GITHUB_PROVIDER = "github";
export const GITHUB_DEFAULT_HOST = "github.com";
const DISCOVERY_DEFAULT_PAGE_SIZE = 30;
const DISCOVERY_MAX_PAGE_SIZE = 100;
const STATE_TTL_MS = 10 * 60 * 1000;
/** Raw GitHub repository shape (only the fields we consume). */
export interface GitHubRepository {
id: number | string;
name: string;
full_name: string;
owner: { login: string; id?: number | string; type?: string };
clone_url: string;
html_url?: string;
default_branch?: string | null;
private?: boolean;
visibility?: string | null;
archived?: boolean;
fork?: boolean;
}
export interface GitHubInstallation {
id: number | string;
account: { id?: number | string; login: string; type?: string; html_url?: string } | null;
app_slug?: string;
target_type?: string;
repository_selection?: string;
}
export interface GitHubInstallationToken {
token: string;
expiresAt: Date;
}
/**
* Injectable transport that isolates all GitHub App authentication (App JWT
* minting, installation-token exchange) and HTTP access behind a small surface.
* The real transport holds the app private key; tests inject a fake. The
* private key/token never cross back into the provider's persisted output.
*/
export interface GitHubAppTransport {
readonly host: string;
readonly apiBaseUrl: string;
getInstallation(installationId: string): Promise<GitHubInstallation | null>;
createInstallationAccessToken(installationId: string): Promise<GitHubInstallationToken>;
listRepositories(input: {
installationId: string;
page: number;
perPage: number;
query?: string | null;
}): Promise<{ totalCount: number | null; items: GitHubRepository[] }>;
getRepository(input: {
installationId: string;
providerRepositoryId: string;
}): Promise<GitHubRepository | null>;
}
export interface GitHubProviderConfig {
/** App slug used to build the install URL (github.com/apps/<slug>/installations/new). */
appSlug: string;
/** Secret used to sign expiring connection state. Never persisted. */
stateSecret: string;
}
export interface GitHubProviderDeps {
transport: GitHubAppTransport;
config: GitHubProviderConfig;
clock?: () => Date;
nonce?: () => string;
}
function mapVisibility(repo: GitHubRepository): RepositoryVisibility {
if (repo.visibility === "public" || repo.visibility === "private" || repo.visibility === "internal") {
return repo.visibility;
}
if (repo.private === true) return "private";
if (repo.private === false) return "public";
return "unknown";
}
function toSnapshot(repo: GitHubRepository): ProviderRepositorySnapshot {
const metadata = sanitizeRepositoryProviderMetadata({
full_name: repo.full_name,
owner: repo.owner?.login ?? null,
owner_type: repo.owner?.type ?? null,
archived: repo.archived ?? false,
fork: repo.fork ?? false,
});
return {
providerRepositoryId: String(repo.id),
cloneUrl: repo.clone_url,
webUrl: repo.html_url ?? null,
defaultBranch: repo.default_branch ?? null,
visibility: mapVisibility(repo),
// A repository archived on the provider stays importable but is surfaced as
// unavailable so operators see it cannot be worked on without unarchiving.
state: repo.archived ? "unavailable" : "active",
unavailableReason: repo.archived ? "Repository is archived on GitHub" : null,
providerMetadata: metadata,
};
}
function toDiscovered(repo: GitHubRepository): DiscoveredRepository {
const normalized = normalizeRepositoryLocator(repo.clone_url);
return {
providerRepositoryId: String(repo.id),
owner: normalized.owner,
name: normalized.name,
cloneUrl: normalized.cloneUrl,
webUrl: repo.html_url ?? normalized.webUrl,
defaultBranch: repo.default_branch ?? null,
visibility: mapVisibility(repo),
archived: repo.archived ?? false,
metadata: sanitizeRepositoryProviderMetadata({
full_name: repo.full_name,
owner: repo.owner?.login ?? null,
fork: repo.fork ?? false,
}),
};
}
function clampPageSize(pageSize: number | undefined): number {
if (!pageSize || !Number.isFinite(pageSize)) return DISCOVERY_DEFAULT_PAGE_SIZE;
return Math.min(DISCOVERY_MAX_PAGE_SIZE, Math.max(1, Math.floor(pageSize)));
}
function parseCursor(cursor: string | null | undefined): number {
if (!cursor) return 1;
const page = Number.parseInt(cursor, 10);
if (!Number.isFinite(page) || page < 1) throw unprocessable("Discovery cursor is invalid");
return page;
}
function requireInstallationId(connection: RepositoryConnection): string {
if (!connection.installationId) throw unprocessable("GitHub connection is missing an installation id");
return connection.installationId;
}
export function createGitHubProvider(deps: GitHubProviderDeps): RepositoryProviderConnector {
const clock = deps.clock ?? (() => new Date());
const nonce = deps.nonce ?? (() => randomUUID());
const host = deps.transport.host.toLowerCase();
return {
provider: GITHUB_PROVIDER,
host,
async beginInstallation(input: BeginInstallationInput): Promise<BeginInstallationResult> {
if (!input.companyId || !input.userId) throw unprocessable("Installation requires a company and user");
const { state, payload } = signConnectionState({
companyId: input.companyId,
userId: input.userId,
provider: GITHUB_PROVIDER,
redirectPath: input.redirectPath ?? null,
secret: deps.config.stateSecret,
ttlMs: STATE_TTL_MS,
nonce: nonce(),
now: clock(),
});
const installUrl = `https://${host}/apps/${encodeURIComponent(deps.config.appSlug)}/installations/new?state=${encodeURIComponent(state)}`;
return { installUrl, state, expiresAt: new Date(payload.exp) };
},
async completeInstallation(input: CompleteInstallationInput): Promise<CompleteInstallationResult> {
const payload = verifyConnectionState({
state: input.state,
provider: GITHUB_PROVIDER,
secret: deps.config.stateSecret,
now: clock(),
});
if (payload.companyId !== input.companyId) {
throw conflict("Connection state does not match the completing company");
}
if (!input.installationId) throw unprocessable("Installation callback is missing an installation id");
const installation = await deps.transport.getInstallation(input.installationId);
if (!installation) throw unprocessable("GitHub installation was not found");
return {
companyId: payload.companyId,
userId: payload.userId,
installationId: String(installation.id),
accountId: installation.account?.id != null ? String(installation.account.id) : null,
accountName: installation.account?.login ?? null,
host,
providerMetadata: sanitizeRepositoryProviderMetadata({
account_login: installation.account?.login ?? null,
account_type: installation.account?.type ?? null,
app_slug: installation.app_slug ?? deps.config.appSlug,
target_type: installation.target_type ?? null,
repository_selection: installation.repository_selection ?? null,
}),
};
},
async discover(input: DiscoverInput): Promise<DiscoverResult> {
const installationId = requireInstallationId(input.connection);
const perPage = clampPageSize(input.pageSize);
const page = parseCursor(input.cursor);
const result = await deps.transport.listRepositories({
installationId,
page,
perPage,
query: input.query?.trim() || null,
});
const items = result.items.map(toDiscovered);
const nextCursor = items.length === perPage ? String(page + 1) : null;
return { items, nextCursor, total: result.totalCount };
},
async refreshMetadata(input: RefreshMetadataInput): Promise<ProviderRepositorySnapshot | null> {
const installationId = requireInstallationId(input.connection);
const repo = await deps.transport.getRepository({
installationId,
providerRepositoryId: input.providerRepositoryId,
});
return repo ? toSnapshot(repo) : null;
},
async sync(input: {
connection: RepositoryConnection;
cursor: string | null;
}): Promise<{ repositories: ProviderRepositorySnapshot[]; cursor?: string | null }> {
const installationId = requireInstallationId(input.connection);
const repositories: ProviderRepositorySnapshot[] = [];
let page = 1;
// Walk every installation page so rename/transfer (stable provider id,
// changed owner/name) and newly granted repositories converge idempotently.
for (;;) {
const result = await deps.transport.listRepositories({
installationId,
page,
perPage: DISCOVERY_MAX_PAGE_SIZE,
query: null,
});
for (const repo of result.items) repositories.push(toSnapshot(repo));
if (result.items.length < DISCOVERY_MAX_PAGE_SIZE) break;
page += 1;
}
return { repositories, cursor: null };
},
async resolveCloneCredential(input: ResolveCloneCredentialInput): Promise<ResolveCloneCredentialResult> {
const installationId = requireInstallationId(input.connection);
const minted = await deps.transport.createInstallationAccessToken(installationId);
const username = "x-access-token";
const authenticatedCloneUrl = `https://${username}:${minted.token}@${input.repository.host}/${input.repository.owner}/${input.repository.name}.git`;
return {
username,
token: minted.token,
authenticatedCloneUrl,
expiresAt: minted.expiresAt,
audit: {
provider: GITHUB_PROVIDER,
host,
connectionId: input.connection.id,
repositoryId: input.repository.id,
installationId,
expiresAt: minted.expiresAt,
},
};
},
async disconnect(): Promise<void> {
// GitHub App installations are revoked on GitHub's side; there is no
// server-side token to invalidate because none is ever persisted. The
// connection service marks repositories unavailable transactionally.
},
};
}
@@ -0,0 +1,4 @@
export * from "./provider-contract.js";
export * from "./connection-state.js";
export * from "./github-provider.js";
export * from "./fake-provider.js";
@@ -0,0 +1,147 @@
import type { RepositoryConnection, RepositoryVisibility } from "@paperclipai/shared";
import type { ProviderRepositorySnapshot } from "../repositories.js";
import type { RepositoryProviderAdapter } from "../repository-connections.js";
/**
* First-class repository provider contract.
*
* Foundation ([PAP-17065]) defined the minimal {@link RepositoryProviderAdapter}
* (`sync`/`disconnect`) that the connection service drives. This module extends
* that contract with the seams the GitHub.com connection/discovery path needs:
* installation lifecycle, searchable paginated discovery, metadata refresh, and
* short-lived clone credential resolution.
*
* The extended surface is intentionally provider-agnostic so the fake provider
* used by the conformance harness and the real GitHub.com provider share the
* exact same shape. Credentials, provider tokens, and app/private keys never
* appear on any persisted row — they only flow through the transient results of
* {@link RepositoryProviderConnector.resolveCloneCredential}.
*/
/** A repository as surfaced by provider discovery, before it is imported. */
export interface DiscoveredRepository {
/** Stable provider-scoped id (survives rename/transfer). */
providerRepositoryId: string;
owner: string;
name: string;
cloneUrl: string;
webUrl?: string | null;
defaultBranch?: string | null;
visibility?: RepositoryVisibility;
/** True when the repository is archived on the provider. */
archived?: boolean;
/** Sanitized, secret-free provider metadata for display. */
metadata?: Record<string, unknown> | null;
}
export interface DiscoverInput {
connection: RepositoryConnection;
/** Free-text filter over owner/name; provider decides matching semantics. */
query?: string | null;
/** Opaque pagination cursor returned by a previous page. */
cursor?: string | null;
/** Requested page size; providers may clamp. */
pageSize?: number;
}
export interface DiscoverResult {
items: DiscoveredRepository[];
/** Opaque cursor for the next page, or null when the last page is reached. */
nextCursor: string | null;
/** Total matches when the provider can report it, else null. */
total: number | null;
}
export interface BeginInstallationInput {
companyId: string;
/** User initiating the connection; bound into the signed state. */
userId: string;
/** Optional app-relative path to return to after the provider callback. */
redirectPath?: string | null;
}
export interface BeginInstallationResult {
/** Provider URL the operator visits to authorize/install the app. */
installUrl: string;
/** Signed, expiring state bound to company/user. */
state: string;
/** When the signed state stops being valid. */
expiresAt: Date;
}
export interface CompleteInstallationInput {
/** Signed state echoed back by the provider callback. */
state: string;
/** Installation id reported by the provider callback. */
installationId: string;
/** Company the caller believes it is completing for (defense in depth). */
companyId: string;
}
export interface CompleteInstallationResult {
companyId: string;
userId: string;
installationId: string;
accountId: string | null;
accountName: string | null;
host: string;
/** Sanitized installation/account metadata for the connection row. */
providerMetadata: Record<string, unknown> | null;
}
export interface RefreshMetadataInput {
connection: RepositoryConnection;
providerRepositoryId: string;
}
export interface ResolveCloneCredentialInput {
connection: RepositoryConnection;
repository: {
id: string;
providerRepositoryId: string | null;
owner: string;
name: string;
cloneUrl: string;
host: string;
};
}
/**
* Result of resolving a short-lived clone credential. The `token` is transient
* and MUST NOT be persisted or logged. `audit` carries only secret-free fields
* safe to write to the activity log.
*/
export interface ResolveCloneCredentialResult {
username: string;
token: string;
/** Clone URL with the transient credential embedded. Never persist. */
authenticatedCloneUrl: string;
expiresAt: Date;
audit: {
provider: string;
host: string;
connectionId: string;
repositoryId: string;
installationId: string | null;
expiresAt: Date;
};
}
/**
* The full provider connector contract. Extends the Foundation adapter so a
* connector can be registered directly with `repositoryProviderRegistry`.
*/
export interface RepositoryProviderConnector extends RepositoryProviderAdapter {
readonly provider: string;
readonly host: string;
beginInstallation(input: BeginInstallationInput): Promise<BeginInstallationResult>;
completeInstallation(input: CompleteInstallationInput): Promise<CompleteInstallationResult>;
discover(input: DiscoverInput): Promise<DiscoverResult>;
refreshMetadata(input: RefreshMetadataInput): Promise<ProviderRepositorySnapshot | null>;
resolveCloneCredential(input: ResolveCloneCredentialInput): Promise<ResolveCloneCredentialResult>;
}
export type { ProviderRepositorySnapshot };