fix(github): serialize binding delivery with company admission

Bound binding calls and wait for each in-flight batch before releasing lifecycle locks. Keep archived companies from dispatching saved binding changes. Verify company isolation and the startup history reconciliation contract.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-30 17:16:47 -05:00
1 parent a4dbaff604
commit 2fb56dba9e
2 files changed
+30 -11

No files matched your search

@@ -99,9 +99,10 @@ describeDatabase("GitHub coordinator with PostgreSQL and controlled Cloud HTTP (
const ew = registerGitHubConnectionEventWork(db, coordinator, events, () => true);
const cw = registerGitHubConnectionContinuityWork(db, coordinator, f.service, () => true);
try {
await Promise.all([ew.ready, cw.ready]); expect(leases).toBe(0);
await Promise.all([ew.ready, cw.ready]); expect(leases).toBe(1);
const startupLeases = leases;
await f.service.refreshManagedGitHubGrantAccess(f.connection, f.grant, { actorType: "system", actorId: "test" });
await vi.waitFor(() => expect(leases).toBeGreaterThan(0));
await vi.waitFor(() => expect(leases).toBeGreaterThan(startupLeases));
await vi.advanceTimersByTimeAsync(31_000); await settled(coordinator);
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.grant.id));
expect(saved!.providerTenant!.github!.installationIds).toEqual(["101"]);
@@ -142,6 +143,16 @@ describeDatabase("GitHub coordinator with PostgreSQL and controlled Cloud HTTP (
expect(await restarted.nextGitHubConnectionContinuityAt()).toBeNull();
});
it("settles only the company and grant that own saved binding debt", async () => {
const f = await fixture(["101"]), other = await fixture(["202"]);
await db.update(connectionGrants).set({ providerTenant: { ...f.grant.providerTenant, github: { ...f.grant.providerTenant!.github!,
bindingSync: { id: randomUUID(), removeInstallationIds: [] } } } }).where(eq(connectionGrants.id, f.grant.id));
await f.service.sweepGitHubConnectionContinuity();
expect(bindings).toHaveLength(1); expect(bindings[0]).toMatchObject({ grantId: f.grant.id, installationId: "101" });
const [untouched] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, other.grant.id));
expect(untouched!.providerTenant).toEqual(other.grant.providerTenant);
});
it("retains a real deadline when successful renewal leaves a short-lived credential unchanged", async () => {
const f = await fixture(["101"]); fakeClock();
vi.spyOn(globalThis, "fetch").mockImplementation((url, init) => String(url).startsWith("https://api.github.com")
@@ -6,7 +6,7 @@ import { measureRunPhase } from "../execution-preparation-timing.js";
import { measureRunTransaction } from "../execution-transaction-timing.js";
import { and, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { connectionGrants, toolConnections } from "@paperclipai/db";
import { companies, connectionGrants, toolConnections } from "@paperclipai/db";
import { conflict, HttpError, notFound, unprocessable } from "../../errors.js";
import { PaperclipCloudConnectorError, type PaperclipCloudConnector } from "../paperclip-cloud-connector.js";
@@ -235,19 +235,27 @@ export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector
eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId),
)).for("update").limit(1);
if (!liveConnection || liveConnection.status !== "active" || !liveConnection.enabled) return current;
const [company] = await tx.select({ status: companies.status }).from(companies)
.where(eq(companies.id, connection.companyId)).for("share").limit(1);
if (company?.status !== "active") return current;
const github = current.providerTenant?.github;
if (!github?.bindingSync || github.bindingSync.id !== grant.providerTenant?.github?.bindingSync?.id) return current;
const subject = current.kind === "agent" && current.subjectAgentId ? `agent:${current.subjectAgentId}`
: current.kind === "user" ? current.subjectUserId : null;
if (!subject) throw unprocessable("GitHub webhook subject is unavailable");
for (const installationId of github.installationIds) await cloudConnector.setWebhookBinding({
subject, companyId: connection.companyId, id: `${current.id}_${installationId}`,
installationId, connectionId: connection.id, grantId: current.id, active: true, accessToken: token!,
});
for (const installationId of github.bindingSync.removeInstallationIds) await cloudConnector.setWebhookBinding({
subject, companyId: connection.companyId, id: `${current.id}_${installationId}`,
installationId, connectionId: connection.id, grantId: current.id, active: false,
});
const changes = [
...github.installationIds.map(installationId => ({ installationId, active: true as const })),
...github.bindingSync.removeInstallationIds.map(installationId => ({ installationId, active: false as const })),
];
for (let offset = 0; offset < changes.length; offset += 4) {
const outcomes = await Promise.allSettled(changes.slice(offset, offset + 4).map(change => cloudConnector.setWebhookBinding({
subject, companyId: connection.companyId, id: `${current.id}_${change.installationId}`,
installationId: change.installationId, connectionId: connection.id, grantId: current.id,
...change.active ? { active: true, accessToken: token! } : { active: false },
})));
const failure = outcomes.find(outcome => outcome.status === "rejected");
if (failure?.status === "rejected") throw failure.reason;
}
await notifyGitHubCredentialChange(tx, connection);
const [settled] = await tx.update(connectionGrants).set({
providerTenant: sql`${connectionGrants.providerTenant} #- '{github,bindingSync}' #- '{github,continuityRetry}'`,