diff --git a/server/src/__tests__/helpers/fake-github-transport.ts b/server/src/__tests__/helpers/fake-github-transport.ts new file mode 100644 index 00000000000..4fb6013aea4 --- /dev/null +++ b/server/src/__tests__/helpers/fake-github-transport.ts @@ -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(); + private readonly repositories = new Map(); + 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 { + return this.installations.get(installationId) ?? null; + } + + async createInstallationAccessToken(installationId: string): Promise { + 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 { + const repos = this.repositories.get(input.installationId) ?? []; + return repos.find((r) => String(r.id) === input.providerRepositoryId) ?? null; + } +} + +export function makeGitHubRepository(overrides: Partial & { 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, + }; +} diff --git a/server/src/__tests__/helpers/repository-provider-conformance.ts b/server/src/__tests__/helpers/repository-provider-conformance.ts new file mode 100644 index 00000000000..c2dbc2d6f81 --- /dev/null +++ b/server/src/__tests__/helpers/repository-provider-conformance.ts @@ -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 | 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 { + 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 }); + }); +} diff --git a/server/src/__tests__/repository-providers.test.ts b/server/src/__tests__/repository-providers.test.ts new file mode 100644 index 00000000000..c608e0534fe --- /dev/null +++ b/server/src/__tests__/repository-providers.test.ts @@ -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, installationId: string, repos = [] as ReturnType[]) { + 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/); + }); +}); diff --git a/server/src/services/repository-providers/connection-state.ts b/server/src/services/repository-providers/connection-state.ts new file mode 100644 index 00000000000..cdd9642ce70 --- /dev/null +++ b/server/src/services/repository-providers/connection-state.ts @@ -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; +} diff --git a/server/src/services/repository-providers/fake-provider.ts b/server/src/services/repository-providers/fake-provider.ts new file mode 100644 index 00000000000..c505f07d027 --- /dev/null +++ b/server/src/services/repository-providers/fake-provider.ts @@ -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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // No persisted token to revoke. + }, + }; + } +} diff --git a/server/src/services/repository-providers/github-provider.ts b/server/src/services/repository-providers/github-provider.ts new file mode 100644 index 00000000000..93d95c08885 --- /dev/null +++ b/server/src/services/repository-providers/github-provider.ts @@ -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; + createInstallationAccessToken(installationId: string): Promise; + listRepositories(input: { + installationId: string; + page: number; + perPage: number; + query?: string | null; + }): Promise<{ totalCount: number | null; items: GitHubRepository[] }>; + getRepository(input: { + installationId: string; + providerRepositoryId: string; + }): Promise; +} + +export interface GitHubProviderConfig { + /** App slug used to build the install URL (github.com/apps//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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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. + }, + }; +} diff --git a/server/src/services/repository-providers/index.ts b/server/src/services/repository-providers/index.ts new file mode 100644 index 00000000000..3bbfd9d4d6b --- /dev/null +++ b/server/src/services/repository-providers/index.ts @@ -0,0 +1,4 @@ +export * from "./provider-contract.js"; +export * from "./connection-state.js"; +export * from "./github-provider.js"; +export * from "./fake-provider.js"; diff --git a/server/src/services/repository-providers/provider-contract.ts b/server/src/services/repository-providers/provider-contract.ts new file mode 100644 index 00000000000..bdb0988ac47 --- /dev/null +++ b/server/src/services/repository-providers/provider-contract.ts @@ -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 | 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 | 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; + completeInstallation(input: CompleteInstallationInput): Promise; + + discover(input: DiscoverInput): Promise; + refreshMetadata(input: RefreshMetadataInput): Promise; + + resolveCloneCredential(input: ResolveCloneCredentialInput): Promise; +} + +export type { ProviderRepositorySnapshot };