fix(connections): preserve wizard recovery and repository restrictions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 00:22:55 -05:00
1 parent cf8578a9dd
commit 87945c8cf3
15 files changed
+750 -189

No files matched your search

@@ -1,5 +1,6 @@
import { toolActionRequests, toolInvocations } from "@paperclipai/db";
import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js";
import { secretService } from "../services/secrets.js";
import { githubChatWizardService } from "../services/chat-github-wizard.js";
import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js";
import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js";
@@ -2242,23 +2243,21 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
{ provider: "github", assignedAgentId: f.assignedAgentId },
"owner-user",
);
await db
.insert(chatGitHubRegistrations)
.values({
companyId: f.companyId,
endpointId: bot.id,
userId: "owner-user",
stateHash: randomUUID(),
trustedOrigin: "http://127.0.0.1:3104",
ownerType: "personal",
status: "exchanging",
expiresAt: new Date(Date.now() + 60_000),
handoff: {
cloudId: "claim-recovery",
returnState: "bound",
redemptionId: "same-receipt",
},
});
await db.insert(chatGitHubRegistrations).values({
companyId: f.companyId,
endpointId: bot.id,
userId: "owner-user",
stateHash: randomUUID(),
trustedOrigin: "http://127.0.0.1:3104",
ownerType: "personal",
status: "exchanging",
expiresAt: new Date(Date.now() + 60_000),
handoff: {
cloudId: "claim-recovery",
returnState: "bound",
redemptionId: "same-receipt",
},
});
const claimGitHubApp = vi.fn(async () => ({
kind: "github_app",
registrationId: "claim-recovery",
@@ -2317,6 +2316,69 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
.where(eq(chatGitHubRegistrations.endpointId, bot.id)),
).toHaveLength(1);
});
it("wizard recovers a renamed enrolled origin only after checking bound state and Cloud authority", async () => {
const f = await seedCompany();
const { service } = createService();
const bot = await service.create(
f.companyId,
{ provider: "github", assignedAgentId: f.assignedAgentId },
"owner-user",
);
await db
.insert(chatGitHubRegistrations)
.values({
companyId: f.companyId,
endpointId: bot.id,
userId: "owner-user",
stateHash: createHash("sha256").update("bound-state").digest("hex"),
trustedOrigin: "https://old.example.test",
status: "pending",
expiresAt: new Date(Date.now() + 60_000),
handoff: {
cloudId: "origin-recovery",
returnState: "bound-state",
redemptionId: "same-receipt",
},
});
let enrolledOrigin = "https://wrong.example.test";
const githubApp = vi.fn(async () => ({
id: "origin-recovery",
status: "pending",
returnOrigin: enrolledOrigin,
registrationUrl: "https://github.com/settings/apps/new",
manifest: { public: false },
expiresAt: new Date(Date.now() + 60_000).toISOString(),
}));
const wizard = githubChatWizardService(db, {
origin: () => "https://new.example.test",
connector: () => ({ githubApp }) as unknown as PaperclipCloudConnector,
startDirect: service.startGitHubRegistration,
storeApp: service.storeGitHubApp,
storeCredentials: async () => {},
refreshRepositories: service.refreshGitHubRepositories,
resources: service.listResources,
replaceResources: service.replaceResources,
configure: async () => {},
finish: async () => {},
});
await expect(
wizard.cloudCallback("unbound-state", "origin-recovery"),
).rejects.toThrow("did not match");
expect(githubApp).not.toHaveBeenCalled();
await expect(
wizard.cloudCallback("bound-state", "origin-recovery"),
).rejects.toThrow("did not match");
enrolledOrigin = "https://new.example.test";
expect(
await wizard.cloudCallback("bound-state", "origin-recovery"),
).toContain("https://new.example.test/");
const [session] = await db
.select()
.from(chatGitHubRegistrations)
.where(eq(chatGitHubRegistrations.endpointId, bot.id));
expect(session!.trustedOrigin).toBe(enrolledOrigin);
expect(session!.handoff?.cloudId).toBe("origin-recovery");
});
it("wizard keeps localhost draft choices while waiting for Cloud enrollment", async () => {
const f = await seedCompany();
const { service } = createService();
@@ -2363,21 +2425,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const context = await configuredGitHubEndpoint(f, {}, true);
const bot = await context.service.get(context.endpoint.id),
cloudId = "registration-repair";
await db
.insert(chatGitHubRegistrations)
.values({
companyId: f.companyId,
endpointId: bot.id,
userId: "owner-user",
stateHash: randomUUID(),
trustedOrigin: "https://reviews.example.test",
ownerType: "organization",
ownerLogin: "paperclipai",
appName: "Reviewer",
status: "failed",
expiresAt: new Date(),
handoff: { cloudId, returnState: "bound", redemptionId: "receipt" },
});
await db.insert(chatGitHubRegistrations).values({
companyId: f.companyId,
endpointId: bot.id,
userId: "owner-user",
stateHash: randomUUID(),
trustedOrigin: "https://reviews.example.test",
ownerType: "organization",
ownerLogin: "paperclipai",
appName: "Reviewer",
status: "failed",
expiresAt: new Date(),
handoff: { cloudId, returnState: "bound", redemptionId: "receipt" },
});
const webhookUrl = `https://cloud.example.test/v1/connector/webhooks/github/apps/${cloudId}`;
let repaired = false;
const githubApp = vi.fn(
@@ -2540,21 +2600,20 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
(await f.service.githubWizard.advance(f.endpoint.id, "owner-user")).state,
).toBe("connected");
});
it("imports initial GitHub access without a second picker and leaves later additions disabled", async () => {
it("wizard preserves an explicit empty repository selection at revision zero", async () => {
const f = await seedCompany();
const context = await configuredGitHubEndpoint(f, {}, true);
const current = await context.service.get(context.endpoint.id);
const first = await context.service.listResources(current.id);
const resources = await context.service.listResources(context.endpoint.id);
await context.service.replaceResources(
current.id,
first.map((resource) => ({ id: resource.id, enabled: false })),
context.endpoint.id,
resources.map((resource) => ({ id: resource.id, enabled: false })),
"owner-user",
);
await db
.insert(chatGitHubRegistrations)
.values({
companyId: f.companyId,
endpointId: current.id,
endpointId: context.endpoint.id,
userId: "owner-user",
stateHash: randomUUID(),
trustedOrigin: "https://reviews.example.test",
@@ -2563,6 +2622,275 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
status: "completed",
expiresAt: new Date(Date.now() + 60_000),
});
await context.service.githubWizard.advance(context.endpoint.id, "owner-user");
expect(
(await context.service.listResources(context.endpoint.id)).map(
(resource) => resource.enabled,
),
).toEqual([false]);
// The import fence also protects a stale caller that already planned an import.
await context.service.replaceResources(
context.endpoint.id,
resources.map((resource) => ({ id: resource.id, enabled: true })),
"owner-user",
{ initialGitHubImport: true },
);
expect(
(await context.service.listResources(context.endpoint.id)).map(
(resource) => resource.enabled,
),
).toEqual([false]);
});
it("wizard configures Cloud webhook delivery without a public instance URL", async () => {
const f = await seedCompany();
const context = await configuredGitHubEndpoint(f, {}, true);
const current = await context.service.get(context.endpoint.id);
await db
.update(chatEndpoints)
.set({
status: "draft",
setup: {
...current.setup,
github: {
...current.setup.github,
cloudRegistrationId: "cloud-localhost",
},
},
})
.where(eq(chatEndpoints.id, current.id));
const local = createService(new FakeChatSdkRuntime(), context.providerFetch, {
publicBaseUrl: null,
webhookPublicBaseUrl: null,
});
expect(
(
await local.service.configure(
current.id,
{ action: "configure" },
"owner-user",
)
).status,
).not.toBe("error");
const direct = await local.service.create(
f.companyId,
{ provider: "github", assignedAgentId: f.assignedAgentId },
"owner-user",
);
await expect(
local.service.configure(direct.id, { action: "configure" }, "owner-user"),
).rejects.toThrow("public");
});
it("wizard serializes identity starts and callbacks without overwriting PKCE or handoff state", async () => {
const f = await seedCompany();
const context = await configuredGitHubEndpoint(f, {}, true);
const connectedAppId = (await context.service.get(context.endpoint.id))
.botExternalId;
const vault = secretService(db);
const [connection] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, context.endpoint.connectionId));
const refs = [...connection!.credentialSecretRefs];
for (const key of ["clientId", "clientSecret"]) {
const secret = await vault.create(f.companyId, {
name: `wizard-${key}-${randomUUID()}`,
provider: "local_encrypted",
value: `test-${key}`,
});
await vault.createBinding({
companyId: f.companyId,
secretId: secret.id,
targetType: "tool_connection",
targetId: connection!.id,
configPath: `credentials.${key}`,
});
refs.push({
configPath: `credentials.${key}`,
secretId: secret.id,
versionSelector: "latest",
});
}
await db
.update(toolConnections)
.set({ credentialSecretRefs: refs })
.where(eq(toolConnections.id, connection!.id));
await db
.insert(chatGitHubRegistrations)
.values({
companyId: f.companyId,
endpointId: context.endpoint.id,
userId: "owner-user",
stateHash: randomUUID(),
trustedOrigin: "https://reviews.example.test",
status: "completed",
expiresAt: new Date(Date.now() + 60_000),
handoff: {
cloudId: "",
returnState: "preserve-return",
redemptionId: "preserve-receipt",
webhookSecretHash: "preserve-hash",
},
});
let entered!: () => void,
release!: () => void,
callbackEntered!: () => void,
callbackRelease!: () => void;
const storing = new Promise<void>((resolve) => {
entered = resolve;
});
const storeGate = new Promise<void>((resolve) => {
release = resolve;
});
const exchanging = new Promise<void>((resolve) => {
callbackEntered = resolve;
});
const exchangeGate = new Promise<void>((resolve) => {
callbackRelease = resolve;
});
let writes = 0,
verifier = "",
verifierSecretId = "";
const wizard = githubChatWizardService(db, {
origin: () => "https://reviews.example.test",
connector: () => null,
startDirect: context.service.startGitHubRegistration,
storeApp: context.service.storeGitHubApp,
storeCredentials: async (_id, _user, values) => {
if (++writes === 1) {
entered();
await storeGate;
}
verifier = values.identityCodeVerifier!;
if (verifierSecretId) {
await vault.rotate(verifierSecretId, { value: verifier });
return;
}
const secret = await vault.create(f.companyId, {
name: `wizard-verifier-${randomUUID()}`,
provider: "local_encrypted",
value: verifier,
});
verifierSecretId = secret.id;
await vault.createBinding({
companyId: f.companyId,
secretId: secret.id,
targetType: "tool_connection",
targetId: connection!.id,
configPath: "credentials.identityCodeVerifier",
});
await db
.update(toolConnections)
.set({
credentialSecretRefs: [
...refs,
{
configPath: "credentials.identityCodeVerifier",
secretId: secret.id,
versionSelector: "latest",
},
],
})
.where(eq(toolConnections.id, connection!.id));
},
fetch: (async (input, init) => {
if (String(input).endsWith("/access_token")) {
expect(JSON.parse(String(init?.body)).code_verifier).toBe(verifier);
callbackEntered();
await exchangeGate;
return Response.json({ access_token: "transient" });
}
if (String(input).endsWith("/user"))
return Response.json({
id: 1357,
login: "octocat",
avatar_url: "https://example.test/avatar",
});
if (String(input).includes("/user/installations?"))
return Response.json({
installations: [{ id: 2468, app_id: Number(connectedAppId) }],
});
throw new Error(`Unexpected identity request ${String(input)}`);
}) as typeof fetch,
refreshRepositories: context.service.refreshGitHubRepositories,
resources: context.service.listResources,
replaceResources: context.service.replaceResources,
configure: async () => {},
finish: async () => {},
});
const first = wizard.startIdentity(context.endpoint.id, "owner-user");
await Promise.race([
storing,
first.then(() => {
throw new Error("Expected a paused vault write");
}),
]);
await expect(
wizard.startIdentity(context.endpoint.id, "owner-user"),
).rejects.toThrow("in progress");
release();
const authorization = await first,
state = new URL(authorization.authorizationUrl).searchParams.get("state")!;
const callback = wizard.identityCallback(state, "code");
await Promise.race([
exchanging,
callback.then(() => {
throw new Error("Expected a paused OAuth exchange");
}),
]);
await expect(
wizard.startIdentity(context.endpoint.id, "owner-user"),
).rejects.toThrow("in progress");
callbackRelease();
await callback;
const [session] = await db
.select()
.from(chatGitHubRegistrations)
.where(eq(chatGitHubRegistrations.endpointId, context.endpoint.id));
expect(session!.handoff).toMatchObject({
returnState: "preserve-return",
redemptionId: "preserve-receipt",
webhookSecretHash: "preserve-hash",
identity: { githubUserId: "1357", login: "octocat" },
});
expect(session!.handoff?.identityStateHash).toBeUndefined();
expect(session!.handoff?.identityLeaseId).toBeUndefined();
expect(writes).toBe(2);
});
it("imports initial GitHub access without a second picker and leaves later additions disabled", async () => {
const f = await seedCompany();
const context = await configuredGitHubEndpoint(f, {}, true);
const current = await context.service.get(context.endpoint.id);
const first = await context.service.listResources(current.id);
// Inventory before any user selection: simulate a fresh provider import,
// rather than an explicit user decision to disable every repository.
await db
.update(chatEndpointResources)
.set({ enabled: false })
.where(eq(chatEndpointResources.endpointId, current.id));
await db
.update(chatEndpoints)
.set({
setup: {
...current.setup,
github: {
...current.setup.github,
repositorySelectionSaved: undefined,
initialRepositoryImportPending: true,
},
},
})
.where(eq(chatEndpoints.id, current.id));
await db.insert(chatGitHubRegistrations).values({
companyId: f.companyId,
endpointId: current.id,
userId: "owner-user",
stateHash: randomUUID(),
trustedOrigin: "https://reviews.example.test",
ownerType: "organization",
ownerLogin: "paperclipai",
status: "completed",
expiresAt: new Date(Date.now() + 60_000),
});
const result = await context.service.githubWizard.advance(
current.id,
"owner-user",
@@ -808,6 +808,21 @@ describe("openapi routes", () => {
expect(replacement.responses["422"]).toBeDefined();
});
it("documents the manager-only GitHub wizard and compatible owner-aware registration", () => {
const { spec } = loadSpecRoutes();
for (const [suffix, method] of [["draft", "put"], ["setup", "post"], ["identity/start", "post"], ["identity/confirm", "post"]]) {
const operation = spec.paths[`/api/chat-endpoints/{endpointId}/github/${suffix}`][method];
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
}
const registration = spec.paths["/api/chat-endpoints/{endpointId}/github/registration"].post;
const input = registration.requestBody.content["application/json"].schema;
expect(input.properties.ownerType.enum).toEqual(["personal", "organization"]);
expect(input.required).not.toContain("ownerType");
const response = registration.responses["200"].content["application/json"].schema;
expect(response.oneOf ?? response.anyOf).toHaveLength(2);
});
it("documents auth and reviewed response-code invariants", () => {
const { spec } = loadSpecRoutes();
+17 -3
View File
@@ -2288,6 +2288,16 @@ const githubConfigurationResponseSchema = z.object({
updatedAt: z.string().optional(),
});
const githubPersonResponseSchema = z.object({ githubUserId: z.string(), login: z.string() });
const githubWizardInputSchema = z.object({ name: z.string().trim().min(1).max(34), ownerType: z.enum(["personal", "organization"]), ownerLogin: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/).optional() }).strict();
const githubWizardCheckSchema = z.object({ key: z.string(), label: z.string(), ok: z.boolean(), detail: z.string() });
const githubWizardResponseSchema = z.object({
endpointId: z.string().uuid(), state: z.enum(["create", "install", "identity", "verify", "connected", "recovery", "enrollment"]),
registration: z.object({ registrationUrl: z.string().url(), manifest: z.record(z.string(), z.unknown()), expiresAt: z.string() }).optional(),
installationUrl: z.string().url().optional(), identity: githubPersonResponseSchema.extend({ avatarUrl: z.string().nullable() }).optional(),
identityLinked: z.boolean().optional(), identityMethod: z.enum(["dedicated_app", "existing_connection"]).optional(),
verification: z.object({ ready: z.boolean(), checks: z.array(githubWizardCheckSchema) }).optional(),
runtimeChecks: z.array(githubWizardCheckSchema).optional(), message: z.string().optional(),
});
const githubBotOperations: Array<{
method: string;
suffix: string;
@@ -2349,10 +2359,14 @@ const githubBotOperations: Array<{
},
{
method: "post", suffix: "registration", summary: "Prepare GitHub App manifest registration",
description: "Creates expiring single-use state bound to the current user, company, endpoint, and trusted HTTPS origin. Return data contains the manifest and registration URL, never private App credentials. Response is not cached.",
body: z.object({ name: z.string().trim().min(1).max(34) }).strict(),
response: z.object({ expiresAt: z.string(), registrationUrl: z.string().url(), manifest: z.record(z.string(), z.unknown()) }),
description: "Starts or resumes a private App under a personal account or organization. Supplying ownerType uses the two-screen wizard and Cloud callbacks, including localhost; omitting it preserves legacy direct registration. State remains bound to the member, company, draft, connection, agent, and trusted origin. Credentials are never returned.",
body: githubWizardInputSchema.partial({ ownerType: true }).refine(value => value.ownerType !== "organization" || !!value.ownerLogin, "Enter the GitHub organization"),
response: z.union([z.object({ expiresAt: z.string(), registrationUrl: z.string().url(), manifest: z.record(z.string(), z.unknown()) }), githubWizardResponseSchema]),
},
{ method: "put", suffix: "draft", summary: "Save GitHub App setup choices", description: "Saves the App name and ownership choice on the same assigned draft. Requires connection-management access; creates no provider resources.", body: githubWizardInputSchema, response: chatEndpointResponseSchema },
{ method: "post", suffix: "setup", summary: "Advance GitHub App setup", description: "Resumes credential delivery, installation discovery, repository import, and verification. Returns redacted progress or the required human action. Runtime execution is reported separately.", response: githubWizardResponseSchema },
{ method: "post", suffix: "identity/start", summary: "Authorize your identity with the dedicated GitHub App", description: "Starts a member-bound OAuth authorization with PKCE. Does not grant bot execution access through personal credentials.", response: z.object({ authorizationUrl: z.string().url() }) },
{ method: "post", suffix: "identity/confirm", summary: "Confirm the observed GitHub identity", description: "Confirms only the signed-in configuring member's expiring observed identity and resumes the same setup draft.", body: z.object({ githubUserId: z.string().regex(/^[1-9][0-9]*$/) }).strict(), response: githubWizardResponseSchema },
{
method: "post", suffix: "app", summary: "Connect an existing GitHub App",
description: "Validates App identity with GitHub and vaults write-only credentials server-side. Installation and signed webhook delivery must still be verified.",
+6 -1
View File
@@ -9530,7 +9530,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
) {
throw unprocessable("Unsupported chat endpoint setup action");
}
if (!getWebhookPublicBaseUrl() && endpoint.provider !== "discord" && endpoint.provider !== "imessage-photon") {
if (!getWebhookPublicBaseUrl() && endpoint.provider !== "discord" && endpoint.provider !== "imessage-photon" && !(endpoint.provider === "github" && endpoint.setup.github?.cloudRegistrationId)) {
throw unprocessable(
`A public HTTPS Paperclip URL is required before connecting ${PROVIDER_LABELS[endpoint.provider]}`,
);
@@ -27959,6 +27959,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
endpointId: string,
updates: Array<{ id: string; enabled: boolean }>,
actorUserId?: string | null,
options?: { initialGitHubImport?: boolean },
) {
const initial = await endpointRecord(endpointId);
if (!initial) throw notFound("Chat endpoint not found");
@@ -27977,6 +27978,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
companyId: chatEndpoints.companyId,
connectionId: chatEndpoints.connectionId,
provider: chatEndpoints.provider,
setup: chatEndpoints.setup,
})
.from(chatEndpoints)
.where(
@@ -27987,6 +27989,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
)
.for("no key update");
if (!endpoint) throw notFound("Chat endpoint not found");
if (options?.initialGitHubImport && endpoint.provider === "github" && (endpoint.setup.github?.repositorySelectionSaved || !endpoint.setup.github?.initialRepositoryImportPending)) return;
const rows = await tx
.select({
id: chatEndpointResources.id,
@@ -28040,6 +28043,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
eq(chatEndpointResources.id, entry.id),
),
);
if (endpoint.provider === "github")
await tx.update(chatEndpoints).set({ setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || '{"repositorySelectionSaved":true,"initialRepositoryImportPending":false}'::jsonb)`, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId));
if (changes.length > 0)
await logActivity(
tx as unknown as Db,
+190 -28
View File
@@ -81,6 +81,7 @@ export function githubChatWizardService(
endpointId: string,
updates: Array<{ id: string; enabled: boolean }>,
userId: string,
options?: { initialGitHubImport?: boolean },
) => Promise<unknown>;
configure: (endpointId: string, userId: string) => Promise<unknown>;
finish: (endpointId: string, userId: string) => Promise<unknown>;
@@ -206,6 +207,13 @@ export function githubChatWizardService(
);
}
await saveDraft(id, userId, input);
// Only a fresh registration may import GitHub's initial selection. Old
// drafts without this marker retain even an entirely disabled selection.
if (
!bot.setup.github?.repositorySelectionSaved &&
!(await options.resources(id)).length
)
await setup(bot, { initialRepositoryImportPending: true });
const cloud = connector();
if (!cloud) {
if (origin().startsWith("https://"))
@@ -717,6 +725,8 @@ export function githubChatWizardService(
session &&
bot.status !== "active" &&
saved.revision === 0 &&
bot.setup.github?.initialRepositoryImportPending === true &&
!bot.setup.github?.repositorySelectionSaved &&
!resources.some((resource) => resource.enabled)
) {
await options.replaceResources(
@@ -725,6 +735,7 @@ export function githubChatWizardService(
.filter((resource) => resource.availability === "available")
.map((resource) => ({ id: resource.id, enabled: true })),
userId,
{ initialGitHubImport: true },
);
}
if (session && saved.revision === 0 && bot.status !== "active")
@@ -828,7 +839,95 @@ export function githubChatWizardService(
.limit(1);
return !!link;
}
async function withIdentityLease<T>(
sessionId: string,
work: (assertOwned: () => Promise<void>) => Promise<T>,
): Promise<T> {
const leaseId = nonce();
const leaseUntil = () => new Date(Date.now() + 120_000).toISOString();
const [claimed] = await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`coalesce(${chatGitHubRegistrations.handoff}, '{"cloudId":"","returnState":"","redemptionId":""}'::jsonb) || ${JSON.stringify({ identityLeaseId: leaseId, identityLeaseExpiresAt: leaseUntil() })}::jsonb`,
})
.where(
and(
eq(chatGitHubRegistrations.id, sessionId),
sql`(${chatGitHubRegistrations.handoff}->>'identityLeaseExpiresAt' is null or ${chatGitHubRegistrations.handoff}->>'identityLeaseExpiresAt' <= ${new Date().toISOString()})`,
),
)
.returning({ id: chatGitHubRegistrations.id });
if (!claimed)
throw conflict(
"Another account-linking attempt is in progress. Try again shortly.",
);
let lost = false;
const assertOwned = async () => {
const [owned] = await db
.select({ id: chatGitHubRegistrations.id })
.from(chatGitHubRegistrations)
.where(
and(
eq(chatGitHubRegistrations.id, sessionId),
sql`${chatGitHubRegistrations.handoff}->>'identityLeaseId' = ${leaseId}`,
sql`${chatGitHubRegistrations.handoff}->>'identityLeaseExpiresAt' > ${new Date().toISOString()}`,
),
);
if (lost || !owned)
throw conflict("Account-linking changed. Connect your account again.");
};
const renewal = setInterval(() => {
void db
.update(chatGitHubRegistrations)
.set({
handoff: sql`jsonb_set(${chatGitHubRegistrations.handoff}, '{identityLeaseExpiresAt}', ${JSON.stringify(leaseUntil())}::jsonb)`,
})
.where(
and(
eq(chatGitHubRegistrations.id, sessionId),
sql`${chatGitHubRegistrations.handoff}->>'identityLeaseId' = ${leaseId}`,
),
)
.returning({ id: chatGitHubRegistrations.id })
.then((rows) => {
if (!rows.length) lost = true;
})
.catch(() => {
lost = true;
});
}, 30_000);
renewal.unref();
try {
return await work(assertOwned);
} finally {
clearInterval(renewal);
await db
.update(chatGitHubRegistrations)
.set({
handoff: sql`${chatGitHubRegistrations.handoff} - 'identityLeaseId' - 'identityLeaseExpiresAt'`,
})
.where(
and(
eq(chatGitHubRegistrations.id, sessionId),
sql`${chatGitHubRegistrations.handoff}->>'identityLeaseId' = ${leaseId}`,
),
);
}
}
async function startIdentity(id: string, userId: string) {
const bot = await endpoint(id, userId),
session = await registration(bot);
if (!session || session.userId !== userId)
throw forbidden("The configuring member must link their GitHub account");
return withIdentityLease(session.id, (assertOwned) =>
startIdentityLocked(id, userId, assertOwned),
);
}
async function startIdentityLocked(
id: string,
userId: string,
assertOwned: () => Promise<void>,
) {
const bot = await endpoint(id, userId),
session = await registration(bot);
if (!session || session.userId !== userId)
@@ -843,23 +942,15 @@ export function githubChatWizardService(
);
const verifier = nonce(),
state = nonce();
await assertOwned();
await options.storeCredentials(id, userId, {
identityCodeVerifier: verifier,
});
await assertOwned();
await db
.update(chatGitHubRegistrations)
.set({
handoff: {
...(session.handoff ?? {
cloudId: "",
returnState: "",
redemptionId: nonce(),
}),
identityStateHash: hash(state),
identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
identityRedemptionId: nonce(),
identity: undefined,
},
handoff: sql`(${chatGitHubRegistrations.handoff} - 'identity') || ${JSON.stringify({ identityStateHash: hash(state), identityExpiresAt: new Date(Date.now() + 10 * 60_000).toISOString(), identityRedemptionId: nonce() })}::jsonb`,
})
.where(eq(chatGitHubRegistrations.id, session.id));
const challenge = createHash("sha256").update(verifier).digest("base64url");
@@ -895,6 +986,26 @@ export function githubChatWizardService(
session: typeof chatGitHubRegistrations.$inferSelect,
code: string,
redirectUri: string,
) {
return withIdentityLease(session.id, async (assertOwned) => {
const [current] = await db
.select()
.from(chatGitHubRegistrations)
.where(eq(chatGitHubRegistrations.id, session.id));
if (
!current ||
current.handoff?.identityStateHash !==
session.handoff?.identityStateHash
)
return resume(await endpoint(session.endpointId, session.userId));
return completeIdentityLocked(current, code, redirectUri, assertOwned);
});
}
async function completeIdentityLocked(
session: typeof chatGitHubRegistrations.$inferSelect,
code: string,
redirectUri: string,
assertOwned: () => Promise<void>,
) {
const bot = await endpoint(session.endpointId, session.userId);
if (
@@ -980,21 +1091,20 @@ export function githubChatWizardService(
)
throw conflict("GitHub returned an invalid account");
// OAuth token remains transient; only an expiring observed identity is saved.
await assertOwned();
const identity = {
githubUserId: String(user.id),
login: user.login,
avatarUrl: user.avatar_url ?? null,
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
};
await db
.update(chatGitHubRegistrations)
.set({
handoff: {
...session.handoff!,
identityStateHash: undefined,
identity: {
githubUserId: String(user.id),
login: user.login,
avatarUrl: user.avatar_url ?? null,
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString(),
},
},
handoff: sql`jsonb_set(${chatGitHubRegistrations.handoff}, '{identity}', ${JSON.stringify(identity)}::jsonb)`,
})
.where(eq(chatGitHubRegistrations.id, session.id));
await assertOwned();
await options.storeCredentials(bot.id, session.userId, {
identityCodeVerifier: nonce(),
});
@@ -1037,9 +1147,44 @@ export function githubChatWizardService(
sql`${chatGitHubRegistrations.handoff}->>'cloudId' = ${registrationId}`,
)
.limit(1);
if (!session?.handoff || session.trustedOrigin !== origin())
if (
!session?.handoff ||
(session.stateHash !== hash(state) &&
session.handoff.identityStateHash !== hash(state))
)
throw forbidden("GitHub return did not match this instance");
const bot = await endpoint(session.endpointId, session.userId);
if (session.trustedOrigin !== origin()) {
const cloud = connector();
if (!cloud) throw forbidden("GitHub return did not match this instance");
const current = await cloud.githubApp({
subject: session.userId,
companyId: session.companyId,
binding: { action: "status", id: registrationId },
});
// A manager-authenticated Cloud lookup, rather than a callback parameter,
// proves the current enrolled destination after an instance rename.
if (current.returnOrigin !== origin())
throw forbidden("GitHub return did not match this instance");
await db
.update(chatGitHubRegistrations)
.set({ trustedOrigin: origin() })
.where(
and(
eq(chatGitHubRegistrations.id, session.id),
eq(chatGitHubRegistrations.trustedOrigin, session.trustedOrigin),
),
);
await logActivity(db, {
companyId: session.companyId,
actorType: "system",
actorId: "chat-github-setup",
action: "chat_github.registration_origin_recovered",
entityType: "tool_connection",
entityId: bot.connectionId,
details: { registrationId: session.id },
});
}
if (session.handoff.identityStateHash === hash(state)) {
if (!claimId) {
await db
@@ -1047,7 +1192,12 @@ export function githubChatWizardService(
.set({
handoff: sql`${chatGitHubRegistrations.handoff} - 'identityStateHash'`,
})
.where(eq(chatGitHubRegistrations.id, session.id));
.where(
and(
eq(chatGitHubRegistrations.id, session.id),
sql`${chatGitHubRegistrations.handoff}->>'identityStateHash' = ${hash(state)}`,
),
);
return resume(bot);
}
const cloud = connector();
@@ -1125,11 +1275,23 @@ export function githubChatWizardService(
throw conflict(
"Connect your GitHub account again before confirming its identity",
);
await management.linkObservedIdentity(id, userId, candidate);
await db
.update(chatGitHubRegistrations)
.set({ handoff: { ...session.handoff!, identity: undefined } })
.where(eq(chatGitHubRegistrations.id, session.id));
await withIdentityLease(session.id, async (assertOwned) => {
const [current] = await db
.select()
.from(chatGitHubRegistrations)
.where(eq(chatGitHubRegistrations.id, session.id));
if (
current?.handoff?.identity?.githubUserId !== githubUserId ||
current.handoff.identity.expiresAt !== candidate.expiresAt
)
throw conflict("GitHub identity changed. Connect your account again.");
await assertOwned();
await management.linkObservedIdentity(id, userId, candidate);
await db
.update(chatGitHubRegistrations)
.set({ handoff: sql`${chatGitHubRegistrations.handoff} - 'identity'` })
.where(eq(chatGitHubRegistrations.id, session.id));
});
return advance(id, userId);
}
return {