mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 02:43:04 +02:00
fix(connections): preserve wizard recovery and repository restrictions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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();
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user