mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
Resume committed rotations without asking for another signing secret
Compare the persisted generation with the saved request baseline, keep one-time plaintext separate from reconnect readiness, and reset local state for a new rotation objective. Cover verified, pending-verification, and unchanged generations. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cbf3dc6fc6
commit
d66e96646d
6 files changed
+32
-5
No files matched your search
@@ -151,6 +151,7 @@ export interface ChatEndpoint {
|
||||
slackAccount?: SlackAccountState;
|
||||
slackOAuthCallbackUri?: string | null;
|
||||
webhookVerifiedAt?: string | null;
|
||||
credentialRotationId?: string | null;
|
||||
webhookSecretConfigured?: boolean;
|
||||
callbackSurfaces?: {
|
||||
events: ChatCallbackSurfaceState;
|
||||
|
||||
@@ -295,6 +295,26 @@ describe("GitHub App wizard", () => {
|
||||
expect(githubChatApi.connectApp).not.toHaveBeenCalled();
|
||||
expect(chatEndpointsApi.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
{ generation: "22222222-2222-4222-8222-222222222222", verified: true, canResume: true },
|
||||
{ generation: "22222222-2222-4222-8222-222222222222", verified: false, canResume: true },
|
||||
{ generation: "11111111-1111-4111-8111-111111111111", verified: true, canResume: false },
|
||||
])("resumes only the saved rotation generation without requiring its secret again ($verified, $canResume)", async ({ generation, verified, canResume }) => {
|
||||
fixture.endpoint.status = "attention";
|
||||
fixture.endpoint.botExternalId = "1234";
|
||||
fixture.endpoint.setup.credentialRotationId = generation;
|
||||
fixture.endpoint.setup.webhookVerifiedAt = verified ? new Date().toISOString() : null;
|
||||
vi.mocked(chatEndpointsApi.setup).mockResolvedValue(fixture.endpoint);
|
||||
await render("resume=draft-1&stage=credentials&reconnect=1&rotateSecret=1&afterRotationId=11111111-1111-4111-8111-111111111111");
|
||||
const reconnect = [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")!;
|
||||
expect(reconnect.disabled).toBe(!canResume);
|
||||
expect(container.querySelector("#github-rotation-secret")).toBeNull();
|
||||
expect(chatEndpointsApi.generateSetupSecret).not.toHaveBeenCalled();
|
||||
if (canResume) {
|
||||
await act(async () => reconnect.click());
|
||||
expect(chatEndpointsApi.setup).toHaveBeenCalledWith("draft-1", { action: "reconnect" });
|
||||
}
|
||||
});
|
||||
it("reconnects the same App using vaulted credentials without requiring another paste", async () => {
|
||||
fixture.endpoint.status = "attention";
|
||||
fixture.endpoint.botExternalId = "1234";
|
||||
|
||||
@@ -376,10 +376,11 @@ export function GitHubChatSetup() {
|
||||
</>
|
||||
) : existing ? (
|
||||
<GitHubAppRecovery
|
||||
key={bot.id}
|
||||
key={`${bot.id}:${rotateWebhookSecret ? params.get("afterRotationId") ?? "unspecified" : "credentials"}`}
|
||||
endpoint={bot}
|
||||
reconnect={reconnect}
|
||||
rotateWebhookSecret={rotateWebhookSecret}
|
||||
rotationAlreadyCommitted={params.has("afterRotationId") && !!bot.setup?.credentialRotationId && bot.setup.credentialRotationId !== params.get("afterRotationId")}
|
||||
onExit={exit}
|
||||
onConnect={async (credentials) => {
|
||||
if (reconnect || ["active", "paused", "revoked"].includes(bot.status)) {
|
||||
|
||||
@@ -42,10 +42,11 @@ function CredentialLabel({ id, label, help }: { id: string; label: string; help:
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = false, onConnect, onExit }: {
|
||||
export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = false, rotationAlreadyCommitted = false, onConnect, onExit }: {
|
||||
endpoint: ChatEndpoint;
|
||||
reconnect: boolean;
|
||||
rotateWebhookSecret?: boolean;
|
||||
rotationAlreadyCommitted?: boolean;
|
||||
onConnect: (credentials?: GitHubAppCredentials) => Promise<void>;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
@@ -85,9 +86,9 @@ export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = f
|
||||
</div>}
|
||||
{rotateWebhookSecret && <section aria-label="Rotate webhook secret" className="space-y-3">
|
||||
<h2 className="text-base font-semibold">Rotate the webhook secret</h2>
|
||||
<p className="text-sm">Generate a new secret, then replace the webhook secret in <a href={gitHubAppSettingsUrl(endpoint)} target="_blank" rel="noreferrer" className="underline">GitHub App settings</a>. GitHub must verify the new secret before you reconnect.</p>
|
||||
<p className="text-sm">{rotationAlreadyCommitted ? "The rotated secret is saved. Reconnect using that value; Paperclip will check GitHub’s signed delivery." : <>Generate a new secret, then replace the webhook secret in <a href={gitHubAppSettingsUrl(endpoint)} target="_blank" rel="noreferrer" className="underline">GitHub App settings</a>. GitHub must verify the new secret before you reconnect.</>}</p>
|
||||
<p className="text-sm text-muted-foreground">Generating immediately invalidates the previous secret. This value is shown only here; keep this page open until you have copied it.</p>
|
||||
<Button type="button" variant="outline" disabled={pending} onClick={() => void generateSecret()}>Generate webhook secret</Button>
|
||||
<Button type="button" variant="outline" disabled={pending} onClick={() => void generateSecret()}>{rotationSecret || rotationAlreadyCommitted ? "Generate another secret" : "Generate webhook secret"}</Button>
|
||||
{rotationSecret && <div className="space-y-2">
|
||||
<Label htmlFor="github-rotation-secret">New webhook secret</Label>
|
||||
<Input id="github-rotation-secret" type="password" autoComplete="off" readOnly value={rotationSecret} />
|
||||
@@ -146,7 +147,7 @@ export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = f
|
||||
</div>}
|
||||
</div>}
|
||||
<SetupWizardFooter onSaveExit={onExit} disabled={pending}>
|
||||
<Button aria-label={actionLabel} disabled={pending || incomplete || (rotateWebhookSecret && !rotationSecret)} onClick={() => void connect()}>
|
||||
<Button aria-label={actionLabel} disabled={pending || incomplete || (rotateWebhookSecret && !rotationSecret && !rotationAlreadyCommitted)} onClick={() => void connect()}>
|
||||
{pending && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
|
||||
<span className="sm:hidden" aria-hidden="true">{pending ? "Checking…" : reconnect ? "Reconnect" : "Connect"}</span>
|
||||
<span className="hidden sm:inline" aria-hidden="true">{actionLabel}</span>
|
||||
|
||||
Reference in new issue
Block a user