mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
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 };
|
||||
Reference in new issue
Block a user