mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
Authorize background project Git with explicit Connector consent (#17798)
This commit is contained in:
1 parent
6874fed05c
commit
baec3abd67
37 files changed
+1265
-156
No files matched your search
@@ -98,13 +98,13 @@ rl.on("line", (line) => {
|
||||
id: message.id,
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: ["getData", "performAction"],
|
||||
supportedMethods: ["getData", "performAction", "resourceLifecycle", "workspaceOperation"],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "getData" || method === "performAction") {
|
||||
if (["getData", "performAction", "resourceLifecycle", "workspaceOperation"].includes(method)) {
|
||||
sendNestedHostRequest(message, message.paperclipInvocation?.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { githubCredentialsFixture } from "./helpers/github-credentials-fixture.js";
|
||||
import { createNativeGitHubAccess } from "../services/native-runtime/native-github-access.js";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
@@ -71,129 +72,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await database?.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
}, 60_000);
|
||||
async function seed() {
|
||||
const companyId = randomUUID(),
|
||||
agentId = randomUUID(),
|
||||
runId = randomUUID(),
|
||||
issueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: companyId,
|
||||
issuePrefix: companyId.slice(0, 8),
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Shared",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
});
|
||||
await db
|
||||
.insert(issues)
|
||||
.values({ id: issueId, companyId, title: "Identity test" });
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
contextSnapshot: { issueId },
|
||||
});
|
||||
await db.insert(companyMemberships).values(
|
||||
["A", "B"].map((principalId) => ({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
})),
|
||||
);
|
||||
await initializeRunIdentity(db, {
|
||||
companyId,
|
||||
runId,
|
||||
responsibleUserId: "A",
|
||||
cause: "instruction",
|
||||
});
|
||||
return { companyId, agentId, runId, issueId };
|
||||
}
|
||||
async function grant(
|
||||
input: Awaited<ReturnType<typeof seed>>,
|
||||
user: string,
|
||||
dedicated = false,
|
||||
) {
|
||||
const applicationId = randomUUID(),
|
||||
connectionId = randomUUID(),
|
||||
secretId = randomUUID(),
|
||||
definitionId = randomUUID(),
|
||||
id = randomUUID();
|
||||
await db.insert(toolApplications).values({
|
||||
id: applicationId,
|
||||
companyId: input.companyId,
|
||||
name: applicationId,
|
||||
type: "mcp_http",
|
||||
});
|
||||
await db.insert(toolConnections).values({
|
||||
id: connectionId,
|
||||
companyId: input.companyId,
|
||||
applicationId,
|
||||
name: connectionId,
|
||||
uid: connectionId,
|
||||
transport: "mcp_remote",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
credentialPolicy: dedicated ? "per_agent" : "per_user",
|
||||
config: { sourceTemplateKey: "github" },
|
||||
});
|
||||
await db.insert(toolConnectionInstalls).values({
|
||||
companyId: input.companyId,
|
||||
connectionId,
|
||||
targetType: "agent",
|
||||
targetId: input.agentId,
|
||||
});
|
||||
if (!dedicated)
|
||||
await db.insert(userSecretDefinitions).values({
|
||||
id: definitionId,
|
||||
companyId: input.companyId,
|
||||
key: definitionId,
|
||||
name: "Test GitHub",
|
||||
});
|
||||
await db.insert(companySecrets).values({
|
||||
id: secretId,
|
||||
companyId: input.companyId,
|
||||
key: secretId,
|
||||
name: `Test token ${secretId}`,
|
||||
scope: dedicated ? "company" : "user",
|
||||
ownerUserId: dedicated ? null : user,
|
||||
userSecretDefinitionId: dedicated ? null : definitionId,
|
||||
});
|
||||
await db.insert(connectionGrants).values({
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
connectionId,
|
||||
kind: dedicated ? "agent" : "user",
|
||||
subjectUserId: dedicated ? null : user,
|
||||
subjectAgentId: dedicated ? input.agentId : null,
|
||||
status: "active",
|
||||
credentialSecretRefs: [
|
||||
{
|
||||
secretId,
|
||||
configPath: "oauth.access_token",
|
||||
versionSelector: "latest",
|
||||
},
|
||||
],
|
||||
providerTenant: {
|
||||
github: {
|
||||
userId: user,
|
||||
login: user,
|
||||
installationCount: 1,
|
||||
repositoryCount: 1,
|
||||
repositorySelection: "selected",
|
||||
installationIds: ["1"],
|
||||
installationOwnerLogins: [user],
|
||||
},
|
||||
},
|
||||
});
|
||||
return { id, connectionId, secretId, definitionId };
|
||||
}
|
||||
const { seed, grant, switchTo } = githubCredentialsFixture(() => db);
|
||||
it.each(["config", "transportConfig"] as const)("reads only current GitHub identity connections via %s", async location => {
|
||||
const input = await seed(), selected = await grant(input, "A");
|
||||
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, selected.connectionId));
|
||||
@@ -260,24 +139,6 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
} finally { capture.flush(); await observed.$client.end(); }
|
||||
});
|
||||
|
||||
async function switchTo(
|
||||
input: Awaited<ReturnType<typeof seed>>,
|
||||
user: string,
|
||||
) {
|
||||
const id = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
authorUserId: user,
|
||||
body: "Next instruction",
|
||||
});
|
||||
const context = await reserveSteeredIdentity(db, {
|
||||
...input,
|
||||
messageId: id,
|
||||
});
|
||||
await acceptSteeredIdentity(db, context!);
|
||||
}
|
||||
it("resolves A → B → A without retaining tokens, and records only redacted diagnostics", async () => {
|
||||
const input = await seed();
|
||||
await grant(input, "A");
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { agents, companies, companyMemberships, companySecrets, connectionGrants, heartbeatRuns, issueComments, issues, toolApplications, toolConnectionInstalls, toolConnections, userSecretDefinitions, type Db } from "@paperclipai/db";
|
||||
import { initializeRunIdentity, reserveSteeredIdentity, acceptSteeredIdentity } from "../../services/run-identity.js";
|
||||
|
||||
export function githubCredentialsFixture(getDb: () => Db) {
|
||||
async function seed() {
|
||||
const db = getDb();
|
||||
const companyId = randomUUID(),
|
||||
agentId = randomUUID(),
|
||||
runId = randomUUID(),
|
||||
issueId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: companyId,
|
||||
issuePrefix: companyId.slice(0, 8),
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Shared",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
});
|
||||
await db
|
||||
.insert(issues)
|
||||
.values({ id: issueId, companyId, title: "Identity test" });
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId,
|
||||
status: "running",
|
||||
contextSnapshot: { issueId },
|
||||
});
|
||||
await db.insert(companyMemberships).values(
|
||||
["A", "B"].map((principalId) => ({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
})),
|
||||
);
|
||||
await initializeRunIdentity(db, {
|
||||
companyId,
|
||||
runId,
|
||||
responsibleUserId: "A",
|
||||
cause: "instruction",
|
||||
});
|
||||
return { companyId, agentId, runId, issueId };
|
||||
}
|
||||
async function grant(
|
||||
input: Awaited<ReturnType<typeof seed>>,
|
||||
user: string,
|
||||
dedicated = false,
|
||||
) {
|
||||
const db = getDb();
|
||||
const applicationId = randomUUID(),
|
||||
connectionId = randomUUID(),
|
||||
secretId = randomUUID(),
|
||||
definitionId = randomUUID(),
|
||||
id = randomUUID();
|
||||
await db.insert(toolApplications).values({
|
||||
id: applicationId,
|
||||
companyId: input.companyId,
|
||||
name: applicationId,
|
||||
type: "mcp_http",
|
||||
});
|
||||
await db.insert(toolConnections).values({
|
||||
id: connectionId,
|
||||
companyId: input.companyId,
|
||||
applicationId,
|
||||
name: connectionId,
|
||||
uid: connectionId,
|
||||
transport: "mcp_remote",
|
||||
status: "active",
|
||||
enabled: true,
|
||||
credentialPolicy: dedicated ? "per_agent" : "per_user",
|
||||
config: { sourceTemplateKey: "github" },
|
||||
});
|
||||
await db.insert(toolConnectionInstalls).values({
|
||||
companyId: input.companyId,
|
||||
connectionId,
|
||||
targetType: "agent",
|
||||
targetId: input.agentId,
|
||||
});
|
||||
if (!dedicated)
|
||||
await db.insert(userSecretDefinitions).values({
|
||||
id: definitionId,
|
||||
companyId: input.companyId,
|
||||
key: definitionId,
|
||||
name: "Test GitHub",
|
||||
});
|
||||
await db.insert(companySecrets).values({
|
||||
id: secretId,
|
||||
companyId: input.companyId,
|
||||
key: secretId,
|
||||
name: `Test token ${secretId}`,
|
||||
scope: dedicated ? "company" : "user",
|
||||
ownerUserId: dedicated ? null : user,
|
||||
userSecretDefinitionId: dedicated ? null : definitionId,
|
||||
});
|
||||
await db.insert(connectionGrants).values({
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
connectionId,
|
||||
kind: dedicated ? "agent" : "user",
|
||||
subjectUserId: dedicated ? null : user,
|
||||
subjectAgentId: dedicated ? input.agentId : null,
|
||||
status: "active",
|
||||
credentialSecretRefs: [
|
||||
{
|
||||
secretId,
|
||||
configPath: "oauth.access_token",
|
||||
versionSelector: "latest",
|
||||
},
|
||||
],
|
||||
providerTenant: {
|
||||
github: {
|
||||
userId: user,
|
||||
login: user,
|
||||
installationCount: 1,
|
||||
repositoryCount: 1,
|
||||
repositorySelection: "selected",
|
||||
installationIds: ["1"],
|
||||
installationOwnerLogins: [user],
|
||||
},
|
||||
},
|
||||
});
|
||||
return { id, connectionId, secretId, definitionId };
|
||||
}
|
||||
async function switchTo(
|
||||
input: Awaited<ReturnType<typeof seed>>,
|
||||
user: string,
|
||||
) {
|
||||
const db = getDb();
|
||||
const id = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
authorUserId: user,
|
||||
body: "Next instruction",
|
||||
});
|
||||
const context = await reserveSteeredIdentity(db, {
|
||||
...input,
|
||||
messageId: id,
|
||||
});
|
||||
await acceptSteeredIdentity(db, context!);
|
||||
}
|
||||
return { seed, grant, switchTo };
|
||||
}
|
||||
@@ -280,6 +280,33 @@ describe("plugin-worker-manager stderr failure context", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("mints project Git scope only for project lifecycle and workspace operations", async () => {
|
||||
const handle = createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, apiVersion: 1,
|
||||
hostHandlers: { "companies.get": (async (_params, context) => context?.invocationScope) as never },
|
||||
});
|
||||
const common = { companyId: "company-a", params: { mode: "echo", requestedCompanyId: "company-a" } };
|
||||
try {
|
||||
await handle.start();
|
||||
await expect(handle.call("resourceLifecycle", { ...common, resourceType: "project", resourceId: "project-a" } as never))
|
||||
.resolves.toEqual({ companyId: "company-a", projectGit: { projectId: "project-a" } });
|
||||
await expect(handle.call("resourceLifecycle", { ...common, resourceType: "agent", resourceId: "agent-a" } as never))
|
||||
.resolves.toEqual({ companyId: "company-a" });
|
||||
for (const kind of ["prepare", "inspect", "remove", "finalize"]) {
|
||||
const params = { ...common, projectId: "project-a", runId: "run-a", agentId: "agent-a", operation: {
|
||||
kind, source: { id: "workspace-a" }, binding: { projectWorkspaceId: "workspace-a" } } };
|
||||
await expect(handle.call("workspaceOperation", params as never)).resolves.toEqual({ companyId: "company-a",
|
||||
...(kind === "finalize" ? {} : { projectGit: { projectId: "project-a", workspaceId: "workspace-a", operation: kind,
|
||||
...(kind === "prepare" ? { runId: "run-a", agentId: "agent-a" } : {}) } }) });
|
||||
}
|
||||
await expect(handle.call("workspaceOperation", { ...common, projectId: "project-a", runId: null, agentId: null,
|
||||
operation: { kind: "prepare", source: { id: "workspace-a" } } } as never)).resolves.toEqual({ companyId: "company-a", projectGit: { projectId: "project-a", workspaceId: "workspace-a", operation: "prepare" } });
|
||||
await expect(handle.call("workspaceOperation", { ...common, projectId: "project-a", runId: null, agentId: "agent-a",
|
||||
operation: { kind: "prepare", source: { id: "workspace-a" } } } as never)).resolves.toEqual({ companyId: "company-a" });
|
||||
} finally { await handle.stop().catch(() => undefined); }
|
||||
});
|
||||
|
||||
it("passes performAction invocation scope to nested worker host calls", async () => {
|
||||
const companiesGet = vi.fn(async (
|
||||
params: { companyId: string },
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { createDb, authUsers, companyMemberships, connectionGrants, executionWorkspaces, heartbeatRuns, instanceUserRoles, issues, projectAccessMembers, projects, projectWorkspaces, resourceLifecycleEvents, toolConnectionInstalls, toolConnections } from "@paperclipai/db";
|
||||
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
||||
import { ghFetch } from "../services/github-fetch.js";
|
||||
import { projectRoutes } from "../routes/projects.js";
|
||||
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { githubCredentialsFixture } from "./helpers/github-credentials-fixture.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
const vault = vi.hoisted(() => ({
|
||||
resolveUserSecretValue: vi.fn(async (_company: string, input: { responsibleUserId: string }) => ({ value: `test-token-${input.responsibleUserId}` })),
|
||||
resolveSecretValue: vi.fn(async () => "test-dedicated-token"),
|
||||
}));
|
||||
vi.mock("../services/secrets.js", () => ({ secretService: () => vault }));
|
||||
vi.mock("../services/github-fetch.js", async importOriginal => ({
|
||||
...await importOriginal<typeof import("../services/github-fetch.js")>(),
|
||||
ghFetch: vi.fn(async () => new Response(null, { status: 404 })),
|
||||
}));
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("explicit project Git authorization", () => {
|
||||
let db: ReturnType<typeof createDb>, database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
const { seed, grant, switchTo } = githubCredentialsFixture(() => db);
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("paperclip-project-git-authorization-");
|
||||
db = createDb(database.connectionString);
|
||||
await db.insert(authUsers).values(["A", "B"].map(id => ({
|
||||
id, name: id, email: `${id}@example.invalid`, createdAt: new Date(), updatedAt: new Date(),
|
||||
})));
|
||||
}, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); }, 60_000);
|
||||
async function projectAuthorizationFixture() {
|
||||
const input = await seed();
|
||||
const selected = await grant(input, "A");
|
||||
const [project] = await db.insert(projects).values({ companyId: input.companyId, name: "Authorized project" }).returning();
|
||||
const [workspace] = await db.insert(projectWorkspaces).values({ companyId: input.companyId, projectId: project.id,
|
||||
name: "Repository", repoUrl: "https://github.com/fixture/project.git" }).returning();
|
||||
await db.update(issues).set({ projectId: project.id, projectWorkspaceId: workspace.id }).where(eq(issues.id, input.issueId));
|
||||
await db.insert(toolConnectionInstalls).values({ companyId: input.companyId, connectionId: selected.connectionId, targetType: "company", targetId: input.companyId });
|
||||
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, selected.id));
|
||||
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
|
||||
lastAccessRefreshAt: "2020-01-01T00:00:00Z", repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: true }] } } }).where(eq(connectionGrants.id, selected.id));
|
||||
const service = projectGitAuthorizationService(db);
|
||||
const request = { companyId: input.companyId, projectId: project.id, workspaceId: workspace.id };
|
||||
return { input, selected, project, workspace, service, request, scope: { projectId: project.id } };
|
||||
}
|
||||
it("uses explicit project consent without a task and journals consent changes", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("Authorize this project");
|
||||
expect(await f.service.read(f.input.companyId, f.project.id, "B")).toMatchObject({ options: [] });
|
||||
await expect(f.service.set(f.input.companyId, f.project.id, "B", f.selected.id)).rejects.toThrow("own active");
|
||||
const saved = await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
expect(saved).toMatchObject({ grantId: f.selected.id, authorizedByUserId: "A" });
|
||||
expect(JSON.stringify(saved)).not.toContain("test-token");
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.input.runId));
|
||||
await db.update(issues).set({ responsibleUserId: "B" }).where(eq(issues.id, f.input.issueId));
|
||||
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
|
||||
expect((await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.resourceId, f.project.id))).length).toBeGreaterThan(0);
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", null);
|
||||
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("Authorize this project");
|
||||
});
|
||||
it("rechecks the consenting user's project access before background credential export", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, f.project.id));
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.input.companyId, projectId: f.project.id, subjectType: "user", subjectId: "A" });
|
||||
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, f.project.id));
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("private");
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rechecks current Cloud owner elevation for consent on another user's private project", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await db.update(projects).set({ visibility: "private", privacyOwnerUserId: "B" }).where(eq(projects.id, f.project.id));
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-tenant-token");
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_API_ORIGIN", "https://cloud.example.invalid");
|
||||
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack");
|
||||
vi.stubEnv("PAPERCLIP_STACK_SLUG", "fixture-stack");
|
||||
const managed = (enabled: boolean) => vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({
|
||||
v: 1, mode: "cloud", catalogVersion: "test", features: { enableOwnerInstanceAdmin: enabled }, plugins: { autoInstall: [] },
|
||||
}));
|
||||
const portfolio = (role = "owner", stackSlug = "fixture-stack") => Response.json({ stacks: [{ stackSlug, role, isCurrent: true }] });
|
||||
const fetch = vi.fn(async () => portfolio());
|
||||
vi.stubGlobal("fetch", fetch); managed(true);
|
||||
try {
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = { type: "board", source: "cloud_tenant", userId: "A",
|
||||
companyIds: [f.input.companyId], isInstanceAdmin: true }; next(); });
|
||||
app.use("/api", projectRoutes(db)); app.use(errorHandler);
|
||||
expect((await request(app).put(`/api/projects/${f.project.id}/git-authorization`).send({ grantId: f.selected.id })).status).toBe(200);
|
||||
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
|
||||
const cleanup = { ...f.scope, workspaceId: f.workspace.id, operation: "remove" as const };
|
||||
expect(await f.service.resolve(f.request, cleanup)).toBe("test-token-A");
|
||||
expect(fetch).toHaveBeenCalledWith(new URL("https://cloud.example.invalid/v1/tenant/portfolio"), expect.objectContaining({
|
||||
headers: expect.objectContaining({ "x-paperclip-cloud-user-id": "A", "x-paperclip-cloud-stack-id": "fixture-stack" }),
|
||||
}));
|
||||
// Stale local roles must never replace current Cloud authority.
|
||||
await db.insert(instanceUserRoles).values({ userId: "A", role: "instance_admin" });
|
||||
for (const denied of ["role", "setting", "unavailable", "stack"] as const) {
|
||||
managed(denied !== "setting");
|
||||
fetch.mockImplementation(async () => denied === "unavailable" ? new Response(null, { status: 503 })
|
||||
: portfolio(denied === "role" ? "admin" : "owner", denied === "stack" ? "different-stack" : "fixture-stack"));
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
await expect(f.service.resolve(f.request, cleanup)).rejects.toThrow("private");
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
}
|
||||
await db.insert(projectAccessMembers).values({ companyId: f.input.companyId, projectId: f.project.id, subjectType: "user", subjectId: "A" });
|
||||
fetch.mockImplementation(async () => new Response(null, { status: 503 }));
|
||||
expect(await f.service.resolve(f.request, cleanup)).toBe("test-token-A");
|
||||
} finally {
|
||||
await db.delete(instanceUserRoles).where(eq(instanceUserRoles.userId, "A"));
|
||||
vi.unstubAllGlobals(); vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
it("does not lend background consent to a low-trust project", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET, companyId: f.input.companyId, projectIds: [f.project.id],
|
||||
} } } }).where(eq(projects.id, f.project.id));
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("low-trust");
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses the connection's current repository access for sources added after project authorization", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
const [workspace] = await db.insert(projectWorkspaces).values({ companyId: f.input.companyId,
|
||||
projectId: f.project.id, name: "Added repository", repoUrl: "https://github.com/fixture/added",
|
||||
metadata: { githubRepositoryUrl: "https://github.com/fixture/added", githubRepositoryPrivate: true },
|
||||
}).returning();
|
||||
const request = { ...f.request, workspaceId: workspace.id };
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
await expect(f.service.resolve(request, f.scope)).rejects.toThrow("does not include this repository");
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.selected.id));
|
||||
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
|
||||
repositoryCount: 2, repositories: [...saved.providerTenant!.github!.repositories!,
|
||||
{ id: "2", fullName: "fixture/added", installationId: "1", private: true }],
|
||||
} } }).where(eq(connectionGrants.id, f.selected.id));
|
||||
expect(await f.service.resolve(request, f.scope)).toBe("test-token-A");
|
||||
await db.update(connectionGrants).set({ providerTenant: saved.providerTenant }).where(eq(connectionGrants.id, f.selected.id));
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
await expect(f.service.resolve(request, f.scope)).rejects.toThrow("does not include this repository");
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it("never requests credentials or requires a connection for a known public repository", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: false } }).where(eq(projectWorkspaces.id, f.workspace.id));
|
||||
await db.update(connectionGrants).set({ providerTenant: null, status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
|
||||
vi.mocked(ghFetch).mockClear(); vault.resolveUserSecretValue.mockClear();
|
||||
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
|
||||
expect(ghFetch).not.toHaveBeenCalled(); expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["inspect", "remove"] as const)("allows authorized %s after archive while denying provisioning and revoked consent", async operation => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: true } })
|
||||
.where(eq(projectWorkspaces.id, f.workspace.id));
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, f.project.id));
|
||||
const scope = { ...f.scope, workspaceId: f.workspace.id, operation };
|
||||
expect(await f.service.resolve(f.request, scope)).toBe("test-token-A");
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
for (const deniedScope of [f.scope, { ...f.scope, operation }, { ...scope, operation: "prepare" as const },
|
||||
{ ...scope, workspaceId: randomUUID() }, { ...scope, runId: f.input.runId, agentId: f.input.agentId }]) {
|
||||
await expect(f.service.resolve(f.request, deniedScope)).rejects.toThrow();
|
||||
}
|
||||
await expect(f.service.set(f.input.companyId, f.project.id, "A", f.selected.id)).rejects.toThrow("Active project");
|
||||
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
|
||||
await expect(f.service.resolve(f.request, scope)).rejects.toThrow("authorization is unavailable");
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", null);
|
||||
await expect(f.service.resolve(f.request, scope)).rejects.toThrow("Authorize this project");
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["revoked", "disabled"])("ignores %s private catalog entries when the current repository is public", async stale => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
if (stale === "revoked") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
|
||||
else await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.selected.connectionId));
|
||||
const current = await grant(f.input, "A");
|
||||
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, current.id));
|
||||
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
|
||||
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: false }] } } }).where(eq(connectionGrants.id, current.id));
|
||||
vault.resolveUserSecretValue.mockClear(); vi.mocked(ghFetch).mockClear();
|
||||
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled(); expect(ghFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([true, false])("uses refreshed catalog visibility after a repository becomes private=%s", async isPrivate => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: !isPrivate } }).where(eq(projectWorkspaces.id, f.workspace.id));
|
||||
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.selected.id));
|
||||
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
|
||||
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: isPrivate }] } } }).where(eq(connectionGrants.id, f.selected.id));
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
expect(await f.service.resolve(f.request, f.scope)).toBe(isPrivate ? "test-token-A" : null);
|
||||
});
|
||||
it("uses repository access refresh time rather than unrelated grant updates for conflicting active catalogs", async () => {
|
||||
const f = await projectAuthorizationFixture(), current = await grant(f.input, "A");
|
||||
for (const [id, privateValue, refreshedAt] of [[f.selected.id, true, "2020-01-01T00:00:00Z"], [current.id, false, "2021-01-01T00:00:00Z"]] as const) {
|
||||
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, id));
|
||||
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
|
||||
lastAccessRefreshAt: refreshedAt, repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: privateValue }] } } }).where(eq(connectionGrants.id, id));
|
||||
}
|
||||
await db.update(connectionGrants).set({ updatedAt: new Date() }).where(eq(connectionGrants.id, f.selected.id));
|
||||
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
|
||||
});
|
||||
it("never lets a legacy catalog's OAuth update outrank an observed repository refresh", async () => {
|
||||
const f = await projectAuthorizationFixture(), legacy = await grant(f.input, "A");
|
||||
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, legacy.id));
|
||||
await db.update(connectionGrants).set({ updatedAt: new Date(), providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
|
||||
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: false }] } } }).where(eq(connectionGrants.id, legacy.id));
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
|
||||
});
|
||||
it("discovers legacy URL-only public sources anonymously and defers unknown private sources", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await db.update(projectWorkspaces).set({ repoUrl: "https://github.com/fixture/legacy" }).where(eq(projectWorkspaces.id, f.workspace.id));
|
||||
vi.mocked(ghFetch).mockResolvedValueOnce(Response.json({ full_name: "fixture/legacy", private: false }));
|
||||
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
|
||||
const [, options] = vi.mocked(ghFetch).mock.calls.at(-1)!;
|
||||
expect(new Headers(options?.headers).has("Authorization")).toBe(false);
|
||||
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("visibility is unavailable");
|
||||
});
|
||||
it.each(["grant", "connection", "membership", "repository", "archive"])("rechecks %s and never falls back to another grant", async change => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
await grant(f.input, "A");
|
||||
if (change === "grant") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
|
||||
if (change === "connection") await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.selected.connectionId));
|
||||
if (change === "membership") await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.companyId, f.input.companyId));
|
||||
if (change === "repository") await db.update(projectWorkspaces).set({ repoUrl: "https://github.com/fixture/other" }).where(eq(projectWorkspaces.id, f.workspace.id));
|
||||
if (change === "archive") await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, f.project.id));
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow();
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it("uses the live foreground run rather than project consent and denies mismatched scopes", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
await grant(f.input, "B");
|
||||
await switchTo(f.input, "B");
|
||||
expect(await f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).toBe("test-token-B");
|
||||
for (const scope of [undefined, { projectId: randomUUID() }, { ...f.scope, workspaceId: randomUUID() }]) {
|
||||
await expect(f.service.resolve(f.request, scope)).rejects.toThrow();
|
||||
}
|
||||
await expect(f.service.resolve({ ...f.request, workspaceId: randomUUID() }, f.scope)).rejects.toThrow();
|
||||
await expect(f.service.resolve({ ...f.request, companyId: randomUUID() }, f.scope)).rejects.toThrow();
|
||||
});
|
||||
it.each(["configured", "bound"])("authorizes a %s workspace selected from another project, but not an unselected source", async selection => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
const [taskProject] = await db.insert(projects).values({ companyId: f.input.companyId, name: "Task project" }).returning();
|
||||
const [bound] = selection === "bound" ? await db.insert(executionWorkspaces).values({
|
||||
companyId: f.input.companyId, projectId: f.project.id, projectWorkspaceId: f.workspace.id,
|
||||
sourceIssueId: f.input.issueId, name: "Selected workspace", mode: "isolated_workspace", strategyType: "git_worktree", status: "idle",
|
||||
}).returning() : [];
|
||||
await db.update(issues).set({ projectId: taskProject.id,
|
||||
projectWorkspaceId: selection === "configured" ? f.workspace.id : null,
|
||||
executionWorkspaceId: bound?.id ?? null,
|
||||
}).where(eq(issues.id, f.input.issueId));
|
||||
const scope = { ...f.scope, workspaceId: f.workspace.id, runId: f.input.runId, agentId: f.input.agentId };
|
||||
expect(await f.service.resolve(f.request, scope)).toBe("test-token-A");
|
||||
const [unselected] = await db.insert(projectWorkspaces).values({ companyId: f.input.companyId,
|
||||
projectId: f.project.id, name: "Unselected source", repoUrl: f.workspace.repoUrl,
|
||||
}).returning();
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
await expect(f.service.resolve({ ...f.request, workspaceId: unselected.id }, { ...scope, workspaceId: unselected.id })).rejects.toThrow();
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.input.runId));
|
||||
await expect(f.service.resolve(f.request, scope)).rejects.toThrow();
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["", "secret://fixture", "invalid\ntoken"])("rejects invalid transient credential material", async token => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
vault.resolveUserSecretValue.mockResolvedValueOnce({ value: token });
|
||||
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("credential unavailable");
|
||||
});
|
||||
it.each(["task", "source"])("checks access to the %s project before exporting foreground credentials", async privateProject => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
const [taskProject] = await db.insert(projects).values({ companyId: f.input.companyId, name: "Task project" }).returning();
|
||||
await db.update(issues).set({ projectId: taskProject.id }).where(eq(issues.id, f.input.issueId));
|
||||
await db.update(projects).set({ visibility: "private" })
|
||||
.where(eq(projects.id, privateProject === "task" ? taskProject.id : f.project.id));
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
|
||||
try {
|
||||
await expect(f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).rejects.toThrow();
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
} finally { vi.unstubAllEnvs(); }
|
||||
});
|
||||
it("never lends project consent to low-trust foreground preparation", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
|
||||
await db.update(issues).set({ sourceTrust: { preset: LOW_TRUST_REVIEW_PRESET, disposition: "quarantined", sourceIssueId: f.input.issueId } })
|
||||
.where(eq(issues.id, f.input.issueId));
|
||||
vault.resolveUserSecretValue.mockClear();
|
||||
await expect(f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).rejects.toThrow("low-trust");
|
||||
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
|
||||
});
|
||||
it("requires a board project writer and grant owner on the consent route", async () => {
|
||||
const f = await projectAuthorizationFixture();
|
||||
function app(userId: string, agent = false) {
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { req.actor = agent ? { type: "agent", agentId: f.input.agentId, companyId: f.input.companyId }
|
||||
: { type: "board", source: "session", userId, companyIds: [f.input.companyId], isInstanceAdmin: true }; next(); });
|
||||
app.use("/api", projectRoutes(db)); app.use(errorHandler); return app;
|
||||
}
|
||||
const url = `/api/projects/${f.project.id}/git-authorization`;
|
||||
expect((await request(app("B")).put(url).send({ grantId: f.selected.id })).status).toBe(403);
|
||||
expect((await request(app("A", true)).put(url).send({ grantId: f.selected.id })).status).toBe(403);
|
||||
expect((await request(app("A")).put(url).send({ grantId: f.selected.id })).status).toBe(200);
|
||||
const read = await request(app("A")).get(url);
|
||||
expect(read.status).toBe(200); expect(JSON.stringify(read.body)).not.toContain("test-token");
|
||||
expect((await request(app("A")).put(url).send({ grantId: null })).status).toBe(200);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -33,6 +33,14 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(fetched?.workspaces.filter((workspace) => workspace.isPrimary)).toHaveLength(1);
|
||||
expect(fetched?.workspaces.every((workspace) => workspace.companyId === companyId)).toBe(true);
|
||||
});
|
||||
it("retains and refreshes repository visibility without replacing the workspace", async () => {
|
||||
const svc = projectService(db);
|
||||
const source = { ...repo("30"), private: true };
|
||||
const created = await svc.createWithRepositories(companyId, { name: "Visibility" }, [source]);
|
||||
expect(created.workspaces[0].metadata).toMatchObject({ githubRepositoryPrivate: true, githubRepositoryUrl: source.url });
|
||||
const updated = await svc.replaceRepositories(created.id, [{ ...source, private: false }]);
|
||||
expect(updated?.workspaces[0]).toMatchObject({ id: created.workspaces[0].id, metadata: { githubRepositoryPrivate: false, githubRepositoryUrl: source.url } });
|
||||
});
|
||||
it("adds/removes repos, promotes the remaining primary, and preserves legacy and local config", async () => {
|
||||
const svc = projectService(db);
|
||||
const created = await svc.createWithRepositories(companyId, { name: "Edit" }, [repo("3"), repo("4")]);
|
||||
|
||||
@@ -1430,6 +1430,8 @@ const BOARD_ONLY_OPERATIONS = new Set([
|
||||
|
||||
"GET /api/companies/{companyId}/project-repositories",
|
||||
"PUT /api/projects/{id}/repositories",
|
||||
"GET /api/projects/{id}/git-authorization",
|
||||
"PUT /api/projects/{id}/git-authorization",
|
||||
"DELETE /api/issues/{id}/documents/{key}",
|
||||
"GET /api/companies/{companyId}/decisions",
|
||||
"GET /api/cloud/stacks",
|
||||
@@ -5004,6 +5006,25 @@ registry.registerPath({
|
||||
},
|
||||
});
|
||||
|
||||
const projectGitAuthorizationResponse = z.object({
|
||||
grantId: z.string().uuid().nullable(),
|
||||
authorizedByUserId: z.string().nullable(),
|
||||
options: z.array(z.object({ grantId: z.string().uuid(), connectionName: z.string(), login: z.string() })),
|
||||
});
|
||||
registry.registerPath({
|
||||
method: "get", path: "/api/projects/{id}/git-authorization", tags: ["projects"],
|
||||
summary: "Read background project GitHub authorization and the viewer's eligible grants",
|
||||
request: { params: z.object({ id: z.string() }) },
|
||||
responses: { 200: r.ok(projectGitAuthorizationResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
registry.registerPath({
|
||||
method: "put", path: "/api/projects/{id}/git-authorization", tags: ["projects"],
|
||||
summary: "Authorize or revoke a personal GitHub grant for background project work",
|
||||
description: "Board project workspace writers can select only their own active company-installed grant. No task authority or token is stored.",
|
||||
request: { params: z.object({ id: z.string() }), body: jsonBody(z.object({ grantId: z.string().uuid().nullable() }).strict()) },
|
||||
responses: { 200: r.ok(projectGitAuthorizationResponse), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/api/projects/{id}/repositories",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
|
||||
import { skillAccessRequestContext } from "../services/skill-access.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
@@ -296,6 +297,22 @@ export function projectRoutes(db: Db) {
|
||||
res.json(await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted));
|
||||
});
|
||||
|
||||
router.get("/projects/:id/git-authorization", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
|
||||
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
||||
if (!req.actor.userId) throw forbidden("A signed-in user is required");
|
||||
res.json(await projectGitAuthorizationService(db).read(project.companyId, project.id, req.actor.userId));
|
||||
});
|
||||
|
||||
router.put("/projects/:id/git-authorization", validate(z.object({ grantId: z.string().uuid().nullable() }).strict()), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
|
||||
if (!project || !(await assertProjectReadAllowed(req, res, project)) || !(await assertProjectWorkspaceWriteAllowed(req, res, project))) return;
|
||||
if (!req.actor.userId) throw forbidden("A signed-in user is required");
|
||||
res.json(await projectGitAuthorizationService(db).set(project.companyId, project.id, req.actor.userId, req.body.grantId));
|
||||
});
|
||||
|
||||
router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
|
||||
|
||||
@@ -363,6 +363,7 @@ export async function resolveManagedGitHubIdentitySelection(
|
||||
agentId?: string | null;
|
||||
allowStandingDelegation?: boolean;
|
||||
excludeGrantId?: string;
|
||||
explicitGrantId?: string;
|
||||
},
|
||||
): Promise<{
|
||||
configured: boolean;
|
||||
@@ -405,6 +406,7 @@ export async function resolveManagedGitHubIdentitySelection(
|
||||
const grants = await db.select().from(connectionGrants).where(and(
|
||||
eq(connectionGrants.companyId, companyId),
|
||||
inArray(connectionGrants.connectionId, [...eligibleConnectionIds]),
|
||||
context.explicitGrantId ? eq(connectionGrants.id, context.explicitGrantId) : undefined,
|
||||
or(eq(connectionGrants.kind, "agent"), eq(connectionGrants.kind, "user")),
|
||||
));
|
||||
const dedicated = context.agentId
|
||||
@@ -537,6 +539,7 @@ export async function resolveManagedGitHubCredential(
|
||||
responsibleUserId?: string | null;
|
||||
agentId?: string | null;
|
||||
allowStandingDelegation?: boolean;
|
||||
explicitGrantId?: string;
|
||||
},
|
||||
): Promise<{ configured: boolean; identitySource?: "personal" | "dedicated"; credential?: GitCredential; error?: string }> {
|
||||
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
|
||||
@@ -631,6 +634,7 @@ export async function resolveManagedGitHubCredential(
|
||||
} catch {
|
||||
failure = { configured: true, identitySource: selection.identitySource, error: "GitHub credentials are temporarily unavailable" };
|
||||
}
|
||||
if (context.explicitGrantId) return failure;
|
||||
// Retry credential acquisition, never the GitHub operation. An alternate
|
||||
// authorization must still belong to this exact principal and account.
|
||||
const alternate = await resolveManagedGitHubIdentitySelection(db, companyId, {
|
||||
|
||||
@@ -47,6 +47,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
||||
"companies.get": ["companies.read"],
|
||||
"projects.list": ["projects.read"],
|
||||
"projects.get": ["projects.read"],
|
||||
"projects.resolveGitCredential": ["projects.git-credentials"],
|
||||
"projects.getInstanceSecrets": ["projects.instance-secrets"],
|
||||
"projects.managed.get": ["projects.managed"],
|
||||
"projects.managed.reconcile": ["projects.managed"],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { projectGitAuthorizationService } from "./project-git-authorization.js";
|
||||
import { projectInstanceSecretService } from "./project-instance-secrets.js";
|
||||
import { pluginAgentRuntimeState, pluginAgentRuntimeIdentity } from "./plugin-agent-runtime-state.js";
|
||||
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
||||
@@ -1863,6 +1864,11 @@ export function buildHostServices(
|
||||
},
|
||||
|
||||
projects: {
|
||||
async resolveGitCredential(params, context) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
return projectGitAuthorizationService(db).resolve(params, context.invocationScope?.projectGit);
|
||||
},
|
||||
async getInstanceSecrets(params) {
|
||||
const companyId = ensureCompanyId(params.companyId);
|
||||
await ensurePluginAvailableForCompany(companyId);
|
||||
|
||||
@@ -1158,7 +1158,26 @@ export function createPluginWorkerHandle(
|
||||
if (!isRecord(params)) return null;
|
||||
|
||||
const directCompanyId = readNonEmptyString(params.companyId);
|
||||
if (directCompanyId) return { companyId: directCompanyId };
|
||||
if (directCompanyId) {
|
||||
if (method === "resourceLifecycle" && params.resourceType === "project" && readNonEmptyString(params.resourceId)) {
|
||||
return { companyId: directCompanyId, projectGit: { projectId: params.resourceId as string } };
|
||||
}
|
||||
if (method === "workspaceOperation" && isRecord(params.operation)) {
|
||||
const operation = params.operation;
|
||||
const source = operation.kind === "prepare" ? operation.source : operation.binding;
|
||||
const workspaceId = isRecord(source) ? readNonEmptyString(operation.kind === "prepare" ? source.id : source.projectWorkspaceId) : null;
|
||||
const projectId = readNonEmptyString(params.projectId);
|
||||
if (projectId && workspaceId && (operation.kind === "prepare" || operation.kind === "inspect" || operation.kind === "remove")) {
|
||||
// Only foreground preparation may use a run. Maintenance uses project consent.
|
||||
const runId = operation.kind === "prepare" ? readNonEmptyString(params.runId) : null;
|
||||
const agentId = operation.kind === "prepare" ? readNonEmptyString(params.agentId) : null;
|
||||
if (Boolean(runId) !== Boolean(agentId)) return { companyId: directCompanyId };
|
||||
return { companyId: directCompanyId, projectGit: { projectId, workspaceId, operation: operation.kind,
|
||||
...(runId && agentId ? { runId, agentId } : {}) } };
|
||||
}
|
||||
}
|
||||
return { companyId: directCompanyId };
|
||||
}
|
||||
|
||||
if (method === "performAction" && isRecord(params.actorContext)) {
|
||||
const companyId = readNonEmptyString(params.actorContext.companyId);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
companyMemberships,
|
||||
connectionGrants,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
toolConnections,
|
||||
toolConnectionInstalls,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
import type { ProjectGitAuthorization } from "@paperclipai/shared";
|
||||
import { forbidden, notFound } from "../errors.js";
|
||||
import { resolveManagedGitHubCredential } from "./git-credentials.js";
|
||||
import { resolveGitHubOperationCredentials } from "./github-operation-credentials.js";
|
||||
import { normalizeProjectRepositoryUrl, projectRepositoryIsPrivate } from "./project-repositories.js";
|
||||
import { recordProjectLifecycleEvent } from "./resource-lifecycle-events.js";
|
||||
import { persistActivity, publishActivity } from "./activity-log.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-access.js";
|
||||
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
||||
import { isCloudManagedInstance } from "./cloud-instance.js";
|
||||
import { isCurrentCloudOwnerInstanceAdmin, loadCurrentCloudOwnerAuthority } from "./cloud-tenant-authority.js";
|
||||
|
||||
function transientGitToken(token: string | undefined): string {
|
||||
if (!token || token.length > 4096 || /[\0\r\n]/.test(token) || token.startsWith("secret://")) {
|
||||
throw forbidden("GitHub credential unavailable");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Consent is for this exact personal grant, independent of tasks and agent delegation. */
|
||||
export function projectGitAuthorizationService(db: Db) {
|
||||
async function project(companyId: string, projectId: string, allowArchived = false) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)));
|
||||
if (!row || (!allowArchived && row.archivedAt)) throw notFound("Active project not found");
|
||||
return row;
|
||||
}
|
||||
async function eligible(companyId: string, userId: string) {
|
||||
const [member] = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, userId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
);
|
||||
if (!member || member.membershipRole === "viewer") return [];
|
||||
const rows = await db
|
||||
.select({ grant: connectionGrants, connection: toolConnections })
|
||||
.from(connectionGrants)
|
||||
.innerJoin(
|
||||
toolConnections,
|
||||
and(eq(toolConnections.id, connectionGrants.connectionId), eq(toolConnections.companyId, companyId)),
|
||||
)
|
||||
.innerJoin(
|
||||
toolConnectionInstalls,
|
||||
and(
|
||||
eq(toolConnectionInstalls.connectionId, toolConnections.id),
|
||||
eq(toolConnectionInstalls.companyId, companyId),
|
||||
eq(toolConnectionInstalls.targetType, "company"),
|
||||
eq(toolConnectionInstalls.targetId, companyId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.companyId, companyId),
|
||||
eq(connectionGrants.kind, "user"),
|
||||
eq(connectionGrants.subjectUserId, userId),
|
||||
eq(connectionGrants.status, "active"),
|
||||
eq(toolConnections.enabled, true),
|
||||
eq(toolConnections.status, "active"),
|
||||
),
|
||||
);
|
||||
return rows.filter(
|
||||
({ grant, connection }) =>
|
||||
(connection.config?.sourceTemplateKey === "github" ||
|
||||
connection.transportConfig?.sourceTemplateKey === "github") &&
|
||||
grant.providerTenant?.github?.repositoryCount &&
|
||||
grant.providerTenant.github.installationCount > 0,
|
||||
);
|
||||
}
|
||||
async function read(companyId: string, projectId: string, userId: string): Promise<ProjectGitAuthorization> {
|
||||
const row = await project(companyId, projectId, true);
|
||||
return {
|
||||
grantId: row.gitAuthorizationGrantId,
|
||||
authorizedByUserId: row.gitAuthorizedByUserId,
|
||||
options: (await eligible(companyId, userId)).map(({ grant, connection }) => ({
|
||||
grantId: grant.id,
|
||||
connectionName: connection.name,
|
||||
login: grant.providerTenant!.github!.login,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
read,
|
||||
async set(companyId: string, projectId: string, userId: string, grantId: string | null) {
|
||||
const publication = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
|
||||
.for("update");
|
||||
if (!row || (row.archivedAt && grantId)) throw notFound("Active project not found");
|
||||
if (grantId && !(await eligible(companyId, userId)).some(({ grant }) => grant.id === grantId)) {
|
||||
throw forbidden("Select your own active company-installed GitHub authorization");
|
||||
}
|
||||
await tx
|
||||
.update(projects)
|
||||
.set({
|
||||
gitAuthorizationGrantId: grantId,
|
||||
gitAuthorizedByUserId: grantId ? userId : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(projects.id, row.id));
|
||||
await recordProjectLifecycleEvent(tx as unknown as Db, companyId, projectId);
|
||||
const { publication } = await persistActivity(tx as unknown as Db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: grantId ? "project.git_authorized" : "project.git_authorization_revoked",
|
||||
entityType: "project",
|
||||
entityId: projectId,
|
||||
details: { grantId },
|
||||
});
|
||||
return publication;
|
||||
});
|
||||
publishActivity(publication);
|
||||
return read(companyId, projectId, userId);
|
||||
},
|
||||
async resolve(
|
||||
input: { companyId: string; projectId: string; workspaceId: string },
|
||||
scope?: {
|
||||
projectId: string;
|
||||
workspaceId?: string;
|
||||
runId?: string;
|
||||
agentId?: string;
|
||||
operation?: "prepare" | "inspect" | "remove";
|
||||
},
|
||||
) {
|
||||
if (
|
||||
!scope ||
|
||||
scope.projectId !== input.projectId ||
|
||||
(scope.workspaceId && scope.workspaceId !== input.workspaceId)
|
||||
)
|
||||
throw forbidden("Git credentials require a matching host project operation");
|
||||
const cleanup = scope.workspaceId === input.workspaceId && !scope.runId && !scope.agentId &&
|
||||
(scope.operation === "inspect" || scope.operation === "remove");
|
||||
const row = await project(input.companyId, input.projectId, cleanup);
|
||||
const [workspace] = await db
|
||||
.select()
|
||||
.from(projectWorkspaces)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkspaces.companyId, input.companyId),
|
||||
eq(projectWorkspaces.projectId, input.projectId),
|
||||
eq(projectWorkspaces.id, input.workspaceId),
|
||||
),
|
||||
);
|
||||
if (!workspace?.repoUrl) throw forbidden("Git credentials require a configured project repository");
|
||||
const repo = normalizeProjectRepositoryUrl(workspace.repoUrl);
|
||||
const isPrivate = await projectRepositoryIsPrivate(db, input.companyId, workspace);
|
||||
if (scope.runId || scope.agentId) {
|
||||
if (!scope.runId || !scope.agentId) throw forbidden("Incomplete foreground Git identity");
|
||||
const [run] = await db
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot}->>'issueId'`,
|
||||
taskId: sql<string | null>`${heartbeatRuns.contextSnapshot}->>'taskId'`,
|
||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||
nativeIssueId: heartbeatRuns.nativeIssueId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, scope.runId),
|
||||
eq(heartbeatRuns.companyId, input.companyId),
|
||||
eq(heartbeatRuns.agentId, scope.agentId),
|
||||
),
|
||||
);
|
||||
const issueId = run?.issueId ?? run?.nativeIssueId ?? run?.contextIssueId ?? run?.taskId;
|
||||
const [issue] =
|
||||
typeof issueId === "string"
|
||||
? await db
|
||||
.select({ executionWorkspaceId: issues.executionWorkspaceId })
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
|
||||
: [];
|
||||
if (!run || run.status !== "running" || !issue || typeof issueId !== "string")
|
||||
throw forbidden("Active task run is unavailable");
|
||||
await assertTaskWorkspaceExecutionAccess(db, {
|
||||
type: "agent", source: "agent_jwt", companyId: input.companyId,
|
||||
agentId: scope.agentId, runId: scope.runId,
|
||||
onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId,
|
||||
}, input.companyId, {
|
||||
issueId, workspaceId: issue.executionWorkspaceId, projectWorkspaceId: workspace.id,
|
||||
});
|
||||
if (!isPrivate) return null;
|
||||
const result = await resolveGitHubOperationCredentials(db, {
|
||||
companyId: input.companyId,
|
||||
runId: scope.runId,
|
||||
agentId: scope.agentId,
|
||||
});
|
||||
if (result.status !== "available") throw forbidden(result.reason ?? "Run GitHub authorization unavailable");
|
||||
return transientGitToken(result.env.PAPERCLIP_GIT_TOKEN);
|
||||
}
|
||||
if (!isPrivate) return null;
|
||||
if (!row.gitAuthorizationGrantId)
|
||||
throw forbidden("Authorize this project's GitHub connection before provisioning private repositories");
|
||||
const selected =
|
||||
row.gitAuthorizedByUserId &&
|
||||
(await eligible(input.companyId, row.gitAuthorizedByUserId)).find(
|
||||
({ grant }) => grant.id === row.gitAuthorizationGrantId,
|
||||
);
|
||||
if (!row.gitAuthorizedByUserId || !selected)
|
||||
throw forbidden("Project GitHub authorization is unavailable; reconnect or select an authorization");
|
||||
const cloud = isCloudManagedInstance();
|
||||
const isInstanceAdmin = cloud && await isCurrentCloudOwnerInstanceAdmin(db, row.gitAuthorizedByUserId,
|
||||
await loadCurrentCloudOwnerAuthority(row.gitAuthorizedByUserId));
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, {
|
||||
type: "board", source: cloud ? "cloud_tenant" : "session", userId: row.gitAuthorizedByUserId, isInstanceAdmin,
|
||||
}, input.companyId, row.id);
|
||||
if (resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: row }).kind !== "standard")
|
||||
throw forbidden("GitHub credentials are not available to low-trust or unverified projects");
|
||||
// Project consent follows this connection's repository access, including future project sources.
|
||||
if (
|
||||
!selected.grant.providerTenant?.github?.repositories?.some(
|
||||
(r) => r.fullName.toLowerCase() === repo.fullName.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw forbidden("Project GitHub authorization does not include this repository; refresh Connector access");
|
||||
}
|
||||
const resolved = await resolveManagedGitHubCredential(db, secretService(db), input.companyId, {
|
||||
responsibleUserId: row.gitAuthorizedByUserId,
|
||||
explicitGrantId: row.gitAuthorizationGrantId,
|
||||
allowStandingDelegation: false,
|
||||
});
|
||||
if (!resolved.credential || resolved.credential.grantId !== row.gitAuthorizationGrantId)
|
||||
throw forbidden("Project GitHub credential unavailable; reconnect the selected authorization");
|
||||
return transientGitToken(resolved.credential.token);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { connectionGrants, toolConnections } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { ghFetch } from "./github-fetch.js";
|
||||
import type { ProjectRepository, ProjectWorkspace } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { isConnectionGrantAudienceAllowed } from "./tool-gateway.js";
|
||||
@@ -38,7 +41,8 @@ export function resolveProjectRepositorySelection(
|
||||
const current = available.find((repo) => repo.id === id);
|
||||
if (current) return current;
|
||||
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === id && workspace.repoUrl);
|
||||
if (retained) return { id, fullName: retained.name, url: retained.repoUrl!, connections: [] };
|
||||
if (retained) return { id, fullName: retained.name, url: retained.repoUrl!, connections: [],
|
||||
...(typeof retained.metadata?.githubRepositoryPrivate === "boolean" ? { private: retained.metadata.githubRepositoryPrivate } : {}) };
|
||||
throw unprocessable("A selected GitHub repository is no longer available. Refresh repositories and try again.");
|
||||
});
|
||||
}
|
||||
@@ -58,3 +62,40 @@ export function normalizeProjectRepositoryUrl(value: string): { fullName: string
|
||||
}
|
||||
return { fullName: path.slice(1), url: `https://github.com${path}` };
|
||||
}
|
||||
|
||||
/** Use the freshest active catalog visibility (the UI lock), then retained selections. Legacy URL-only
|
||||
* sources need anonymous discovery; unknown visibility must never start a private clone. */
|
||||
export async function projectRepositoryIsPrivate(
|
||||
db: import("@paperclipai/db").Db,
|
||||
companyId: string,
|
||||
workspace: Pick<ProjectWorkspace, "repoUrl" | "metadata">,
|
||||
): Promise<boolean> {
|
||||
const repo = normalizeProjectRepositoryUrl(workspace.repoUrl!);
|
||||
const visibility = await db.select({
|
||||
refreshedAt: sql<string | null>`${connectionGrants.providerTenant}->'github'->>'lastAccessRefreshAt'`,
|
||||
private: sql<boolean | null>`(
|
||||
select bool_or((repository->>'private')::boolean)
|
||||
from jsonb_array_elements(coalesce(${connectionGrants.providerTenant}->'github'->'repositories', '[]'::jsonb)) repository
|
||||
where lower(repository->>'fullName') = ${repo.fullName.toLowerCase()}
|
||||
)` }).from(connectionGrants).innerJoin(toolConnections, and(
|
||||
eq(toolConnections.id, connectionGrants.connectionId), eq(toolConnections.companyId, companyId),
|
||||
)).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.status, "active"),
|
||||
eq(toolConnections.status, "active"), eq(toolConnections.enabled, true)));
|
||||
// OAuth refreshes change updatedAt without observing repository visibility.
|
||||
const current = visibility.filter(row => typeof row.private === "boolean" && Number.isFinite(Date.parse(row.refreshedAt ?? "")))
|
||||
.sort((a, b) => Date.parse(b.refreshedAt!) - Date.parse(a.refreshedAt!))[0];
|
||||
if (current) return current.private!;
|
||||
if (workspace.metadata?.githubRepositoryUrl === repo.url && typeof workspace.metadata.githubRepositoryPrivate === "boolean") {
|
||||
return workspace.metadata.githubRepositoryPrivate;
|
||||
}
|
||||
const legacy = visibility.filter(row => typeof row.private === "boolean");
|
||||
if (legacy.length && legacy.every(row => row.private === legacy[0]!.private)) return legacy[0]!.private!;
|
||||
const response = await ghFetch(`https://api.github.com/repos/${repo.fullName}`, {
|
||||
headers: { Accept: "application/vnd.github+json" }, redirect: "error", signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json() as { full_name?: string; private?: boolean };
|
||||
if (result.full_name?.toLowerCase() === repo.fullName.toLowerCase() && result.private === false) return false;
|
||||
}
|
||||
throw unprocessable("Repository visibility is unavailable; retry discovery or refresh the GitHub repository selection before provisioning.");
|
||||
}
|
||||
@@ -920,7 +920,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
|
||||
const project = await service.create(companyId, data);
|
||||
for (const repo of repositories) {
|
||||
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
|
||||
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
||||
}
|
||||
return (await service.getById(project.id))!;
|
||||
});
|
||||
@@ -946,15 +946,15 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
||||
for (const repo of repositories) {
|
||||
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === repo.id);
|
||||
if (retained) {
|
||||
if (retained.repoUrl !== repo.url || retained.name !== repo.fullName) {
|
||||
await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url });
|
||||
if (retained.repoUrl !== repo.url || retained.name !== repo.fullName || retained.metadata?.githubRepositoryPrivate !== repo.private || retained.metadata?.githubRepositoryUrl !== repo.url) {
|
||||
await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url, metadata: { ...retained.metadata, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const legacy = existing.find((workspace) => !workspace.metadata?.githubRepositoryId && workspace.repoUrl?.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase() === repo.url.toLowerCase());
|
||||
if (legacy) {
|
||||
await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.metadata, githubRepositoryId: repo.id } });
|
||||
} else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
|
||||
await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.metadata, githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
||||
} else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
||||
}
|
||||
await tx.update(projects).set({ updatedAt: new Date() }).where(eq(projects.id, projectId));
|
||||
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, projectId);
|
||||
|
||||
Reference in new issue
Block a user