fix(github): integrate existing-installation reconnect into the shared flow

This commit is contained in:
Hydra
2026-09-29 16:46:46 +03:00
11 changed files with 508 additions and 58 deletions
+2
View File
@@ -11,6 +11,8 @@ the in-app updater surfaces critical advisories from `release-advisories.json`.
identity can connect multiple Openship accounts without transferring sign-in.
Existing installations can be selected, personal tokens remain available in
Git settings, and each connection reports its own completion or failure.
Direct installation links also offer existing accounts before opening GitHub,
avoiding reconnects that end on GitHub settings without a setup callback.
- Compose deployments preserve unchanged image services across folder uploads
and snapshot syncs. Import metadata updates no longer mark services dirty or
overwrite the timestamp of a concurrent configuration edit. Changed project
@@ -10,6 +10,9 @@ const h = vi.hoisted(() => ({
pendingApproval: vi.fn(),
authorize: vi.fn(),
memberFind: vi.fn(),
organizationFind: vi.fn(),
listInstallations: vi.fn(),
currentInstallations: vi.fn(),
claim: vi.fn(),
audit: vi.fn(),
verify: vi.fn(),
@@ -30,8 +33,10 @@ vi.mock("@repo/db", () => ({
pendingApproval: h.pendingApproval,
},
member: { find: h.memberFind },
organization: { findById: h.organizationFind },
gitInstallation: {
claimWithState: h.claim,
listByOrganization: h.currentInstallations,
findByOrgAndOwner: vi.fn(),
},
auditEvent: { create: h.audit },
@@ -43,6 +48,7 @@ vi.mock("@repo/platform/engine/lib/auth", () => ({
}));
vi.mock("@repo/platform/engine/config/env", () => ({
cloudRuntimeTarget: { api: "https://api.openship.io" },
env: { GITHUB_APP_ID: "9" },
}));
vi.mock("@repo/platform/engine/lib/org-actor", () => ({
resolveOrgOwner: vi.fn(),
@@ -57,11 +63,13 @@ vi.mock("@repo/platform/engine/modules/github/github.auth", () => ({
}));
vi.mock("@repo/platform/engine/modules/github/github.installation-verification", () => ({
verifyGitHubInstallationForUser: h.verify,
listGitHubInstallationsForUser: h.listInstallations,
}));
import {
attributeGithubInstall,
buildOrgScopedInstallUrl,
getGithubInstallSelection,
} from "@repo/platform/engine/modules/cloud/cloud-github.service";
const installation = {
@@ -103,6 +111,9 @@ describe("cloud GitHub App installation attribution", () => {
h.pendingApproval.mockResolvedValue(true);
h.authorize.mockResolvedValue(undefined);
h.memberFind.mockResolvedValue({ id: "member_1", role: "member" });
h.organizationFind.mockResolvedValue({ id: "org_1", name: "Acme workspace" });
h.listInstallations.mockResolvedValue([installation]);
h.currentInstallations.mockResolvedValue([]);
h.verify.mockResolvedValue({ kind: "ok", installation });
h.claim.mockResolvedValue({ id: "installation_row" });
h.audit.mockResolvedValue({});
@@ -118,7 +129,7 @@ describe("cloud GitHub App installation attribution", () => {
expect(result.state).toMatch(/^ghrepo_[A-Za-z0-9_-]{32}$/);
expect(result.url).toBe(
`https://github.com/apps/openship-io/installations/new?state=${result.state}`,
`https://api.openship.io/api/cloud/github/install-callback?flow=select&state=${result.state}`,
);
expect(h.stateCreate).toHaveBeenCalledWith(expect.objectContaining({
state: result.state,
@@ -127,6 +138,54 @@ describe("cloud GitHub App installation attribution", () => {
}));
});
it("offers only active installations of this App without claiming or consuming the state", async () => {
h.listInstallations.mockResolvedValue([
installation,
{ ...installation, id: 43, app_id: 10 },
{ ...installation, id: 44, suspended_at: "2026-09-29T00:00:00Z" },
]);
await expect(getGithubInstallSelection("nonce")).resolves.toEqual({
kind: "ready",
state: "nonce",
workspaceName: "Acme workspace",
installUrl: "https://github.com/apps/openship-io/installations/new?state=nonce",
installations: [{ id: 42, login: "Acme", avatarUrl: "", type: "Organization", connected: false }],
});
expect(h.listInstallations).toHaveBeenCalledWith("user_1");
expect(h.claim).not.toHaveBeenCalled();
expect(h.stateConsume).not.toHaveBeenCalled();
});
it.each([
null,
{ organizationId: null, sourceId: null, flow: "install" },
{ organizationId: "org_1", sourceId: null, flow: "manifest" },
{ organizationId: "org_1", sourceId: "custom_app", flow: "install" },
])(
"does not disclose GitHub accounts for an expired or unrelated setup state: %j",
async (binding) => {
h.stateFind.mockResolvedValue(binding);
await expect(getGithubInstallSelection("nonce")).resolves.toEqual({ kind: "state-expired" });
expect(h.listInstallations).not.toHaveBeenCalled();
},
);
it("revokes selection access after the initiating user leaves the workspace", async () => {
h.memberFind.mockResolvedValue(null);
await expect(getGithubInstallSelection("nonce")).resolves.toMatchObject({ kind: "forbidden" });
expect(h.listInstallations).not.toHaveBeenCalled();
});
it("requires GitHub authorization before offering existing installations", async () => {
h.listInstallations.mockResolvedValue(null);
await expect(getGithubInstallSelection("nonce")).resolves.toMatchObject({ kind: "forbidden" });
expect(h.claim).not.toHaveBeenCalled();
});
it("rejects invalid installation ids before reading or burning state", async () => {
const result = await attributeGithubInstall({
...callbackInput,
@@ -69,6 +69,8 @@ import {
import {
startGithubLinkFromBridgeToken,
buildOrgScopedInstallUrl,
getGithubInstallSelection,
type GithubInstallSelectionResult,
attributeGithubInstall,
listOrgInstallations,
mintOrgInstallationToken,
@@ -917,9 +919,9 @@ export async function githubOauthSuccess(c: Context) {
/**
* POST /api/cloud/github/install-url
*
* Returns the central App's installation URL with a one-time state token
* embedded as a query parameter. GitHub Apps preserve the `state` query
* param through the install flow and append it to the Setup URL alongside
* Returns Openship's account-selection URL with a one-time state token. It
* offers existing installations, or forwards a new install to GitHub with the
* same state. GitHub preserves that state and appends it to the Setup URL alongside
* `installation_id` and `setup_action` — that's what lets us attribute
* the install back to the userId that started the flow without requiring
* a SaaS session cookie on the popup browser (the App's Setup URL on
@@ -944,6 +946,10 @@ export async function githubInstallUrl(c: Context) {
* committed atomically. No browser session is trusted on this callback.
*/
export async function githubInstallCallback(c: Context) {
c.header("Cache-Control", "no-store");
c.header("Referrer-Policy", "no-referrer");
if (c.req.query("flow") === "select") return githubInstallSelection(c);
const result = await attributeGithubInstall({
installationIdRaw: c.req.query("installation_id"),
setupAction: c.req.query("setup_action"),
@@ -1011,14 +1017,70 @@ export async function githubInstallCallback(c: Context) {
}
}
async function githubInstallSelection(c: Context) {
const result = await getGithubInstallSelection(c.req.query("state"));
switch (result.kind) {
case "ready":
if (result.installations.length === 0) return c.redirect(result.installUrl);
return c.html(
renderCallbackHtml(
"Connect GitHub",
`Choose the GitHub account to connect to ${result.workspaceName}.`,
{ selection: result },
),
);
case "missing-params":
case "state-expired":
return c.html(
renderCallbackHtml(
"Install link expired",
"This installation link expired or was already used. Start a new connection from Openship.",
),
400,
);
case "forbidden":
return c.html(renderCallbackHtml("Installation not authorized", result.message), 403);
case "failed":
console.error("[github install-selection] failed:", result.error);
return c.html(
renderCallbackHtml(
"Could not load GitHub accounts",
"Your GitHub accounts could not be loaded. Refresh this page to try again.",
),
502,
);
}
}
function renderCallbackHtml(
title: string,
message: string,
opts?: { closeAfterMs?: number },
opts?: {
closeAfterMs?: number;
selection?: Extract<GithubInstallSelectionResult, { kind: "ready" }>;
},
): string {
const closeScript = opts?.closeAfterMs
? `<script>setTimeout(() => window.close(), ${opts.closeAfterMs});</script>`
: "";
const selection = opts?.selection;
const accounts = selection
? `<div class="installations">${selection.installations
.map(
(installation) => `
<form method="get" action="/api/cloud/github/install-callback">
<input type="hidden" name="state" value="${escapeHtml(selection.state)}" />
<input type="hidden" name="installation_id" value="${escapeHtml(String(installation.id))}" />
<input type="hidden" name="setup_action" value="update" />
<button type="submit">
<strong>${escapeHtml(installation.login)}</strong>
<span>${installation.type === "Organization" ? "Organization" : "Personal account"}</span>
</button>
</form>`,
)
.join("")}</div>
<a class="install-another" href="${escapeHtml(selection.installUrl)}">Install on another GitHub account</a>`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
@@ -1030,12 +1092,20 @@ function renderCallbackHtml(
.card { background: #fff; border-radius: 12px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06); }
h1 { font-size: 18px; font-weight: 600; margin: 0 0 12px; }
p { font-size: 14px; line-height: 1.55; color: #555; margin: 0; }
.installations { display: grid; gap: 12px; margin-top: 24px; }
.installations button { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 14px 16px; border: 1px solid #ddd; border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.installations button:hover { background: #f6f6f6; }
.installations button:focus-visible, .install-another:focus-visible { outline: 2px solid #171717; outline-offset: 3px; }
.installations strong { overflow-wrap: anywhere; }
.installations span { font-size: 12px; color: #666; }
.install-another { display: inline-block; margin-top: 20px; font-size: 14px; color: #333; }
</style>
</head>
<body>
<div class="card">
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>
${accounts}
</div>
${closeScript}
</body>
@@ -12,7 +12,10 @@ vi.mock("@repo/platform/engine/modules/github/github.auth", () => ({
}));
vi.mock("@repo/platform/engine/modules/github/github.http", () => ({ ghFetch: h.ghFetch }));
import { verifyGitHubInstallationForUser } from "@repo/platform/engine/modules/github/github.installation-verification";
import {
listGitHubInstallationsForUser,
verifyGitHubInstallationForUser,
} from "@repo/platform/engine/modules/github/github.installation-verification";
const installation = {
id: 42,
@@ -38,10 +41,26 @@ describe("verifyGitHubInstallationForUser", () => {
kind: "forbidden",
reason: "missing-user-token",
});
await expect(listGitHubInstallationsForUser("user_1")).resolves.toBeNull();
expect(h.ghFetch).not.toHaveBeenCalled();
expect(h.appFetch).not.toHaveBeenCalled();
});
it("offers accounts from every page of the user's installation catalog", async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => ({
...installation,
id: index + 1_000,
}));
h.ghFetch
.mockResolvedValueOnce({ total_count: 101, installations: firstPage })
.mockResolvedValueOnce({ total_count: 101, installations: [installation] });
await expect(listGitHubInstallationsForUser("user_1")).resolves.toEqual([
...firstPage,
installation,
]);
});
it("rejects an installation id the initiating user cannot access", async () => {
h.ghFetch.mockResolvedValue({ total_count: 0, installations: [] });
@@ -84,6 +84,30 @@ async function start(actor: SeededOwner) {
return { ...c, state: result.state };
}
async function startInstallLink(actor: SeededOwner) {
const c = await clients(actor);
// Status explicitly requested with its public install link is also used by
// external browsers and the self-hosted Cloud bridge.
const result = await c.http.github.getStatus();
const state = new URL(result.installUrl).searchParams.get("state");
if (!state) throw new Error("Expected a workspace-bound installation link");
return { ...c, state, url: result.installUrl };
}
function selectInstallation(html: string, installationId: number) {
const form = html
.match(/<form\b[\s\S]*?<\/form>/g)
?.find((entry) => entry.includes(`name="installation_id" value="${installationId}"`));
if (!form) throw new Error(`No connection form for installation ${installationId}`);
const action = form.match(/action="([^"]+)"/)?.[1];
if (!action) throw new Error("Missing installation callback destination");
const fields = [...form.matchAll(/name="([^"]+)" value="([^"]*)"/g)].map((match) => [
match[1]!,
match[2]!,
]);
return app.request(`${action}?${new URLSearchParams(fields)}`);
}
function callback(state: string, setupAction = "install") {
return app.request(
`https://api.openship.test/api/cloud/github/install-callback?${new URLSearchParams({
@@ -109,6 +133,119 @@ beforeEach(() => {
});
describe("Cloud GitHub installation through HTTP, shared engine, and database", () => {
it("reconnects an existing GitHub installation without another GitHub install callback", async () => {
const actor = await seedOwner();
const c = await startInstallLink(actor);
// GitHub already has the App, while Openship has no workspace binding.
// The old URL goes to GitHub's settings page and never returns a callback.
const page = await app.request(c.url);
expect(page.status).toBe(200);
expect(page.headers.get("cache-control")).toBe("no-store");
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
const html = await page.text();
expect(html).toContain("Acme");
expect(html).toContain("Test Org");
expect(await repos.gitInstallation.listByOrganization(actor.orgId)).toEqual([]);
expect(await repos.githubInstallState.find(c.state)).toBeTruthy();
const response = await selectInstallation(html, 42);
expect(response.status).toBe(200);
expect(await response.text()).toContain("GitHub App installed");
expect(await c.http.github.getStatus({ includeInstallUrl: false })).toMatchObject({
state: { sources: { openshipApp: { connected: true, hasInstallations: true } } },
accounts: [{ login: "acme", source: "app" }],
});
expect(await repos.githubInstallState.find(c.state)).toBeFalsy();
expect(await c.http.github.pollConnect({ state: c.state })).toEqual({ status: "complete" });
});
it("links only the selected account when GitHub has several existing installations", async () => {
const actor = await seedOwner();
const selected = {
...installation,
id: 43,
account: { ...installation.account, login: "SecondAccount", id: 701 },
};
github.fetch.mockResolvedValue({
total_count: 3,
installations: [
installation,
selected,
{
...installation,
id: 44,
app_id: 10,
account: { ...installation.account, login: "UnrelatedApp" },
},
],
});
await db
.update(schema.organization)
.set({ name: "Team <script>alert(1)</script>" })
.where(eq(schema.organization.id, actor.orgId));
const c = await startInstallLink(actor);
const page = await app.request(c.url);
expect(page.status).toBe(200);
const html = await page.text();
expect(html).toContain("Acme");
expect(html).toContain("SecondAccount");
expect(html).not.toContain("UnrelatedApp");
expect(html).toContain("Team &lt;script&gt;alert(1)&lt;/script&gt;");
expect(html).not.toContain("<script>alert(1)</script>");
github.appFetch.mockResolvedValue(selected);
expect((await selectInstallation(html, 43)).status).toBe(200);
expect(await repos.gitInstallation.listByOrganization(actor.orgId)).toMatchObject([
{ installationId: 43, owner: "secondaccount" },
]);
const other = await clients(await seedOwner());
expect(await other.http.github.getStatus({ includeInstallUrl: false })).toMatchObject({
accounts: [],
});
});
it("forwards a new installation to GitHub with the original workspace state", async () => {
const actor = await seedOwner();
const c = await startInstallLink(actor);
github.fetch.mockResolvedValueOnce({ total_count: 0, installations: [] });
const response = await app.request(c.url);
expect(response.status).toBe(302);
const destination = new URL(response.headers.get("location")!);
expect(destination.origin).toBe("https://github.com");
expect(destination.pathname).toMatch(/\/apps\/[^/]+\/installations\/new$/);
expect(destination.searchParams.get("state")).toBe(c.state);
expect(await repos.githubInstallState.find(c.state)).toBeTruthy();
expect((await callback(c.state)).status).toBe(200);
});
it("rechecks GitHub access when an offered installation is selected", async () => {
const actor = await seedOwner();
const c = await startInstallLink(actor);
const page = await app.request(c.url);
expect(page.status).toBe(200);
const html = await page.text();
github.fetch.mockResolvedValue({ total_count: 0, installations: [] });
expect((await selectInstallation(html, 42)).status).toBe(403);
expect(await repos.gitInstallation.listByOrganization(actor.orgId)).toEqual([]);
expect(await repos.githubInstallState.find(c.state)).toBeTruthy();
});
it("keeps a failed installation lookup retryable instead of sending the user back to GitHub", async () => {
const actor = await seedOwner();
const c = await startInstallLink(actor);
github.fetch.mockRejectedValueOnce(new Error("GitHub temporarily unavailable"));
const failed = await app.request(c.url);
expect(failed.status).toBe(502);
expect(failed.headers.get("location")).toBeNull();
expect(await failed.text()).toContain("Could not load GitHub accounts");
expect(await repos.githubInstallState.find(c.state)).toBeTruthy();
expect((await app.request(c.url)).status).toBe(200);
});
it("reads status without minting install nonces, while preserving the normal install URL response", async () => {
const actor = await seedOwner();
const c = await clients(actor);
@@ -366,7 +366,12 @@ describe("Cloud repository authorization from browser callback through library a
it("also checks the issuing session at installation completion", async () => {
const owner = await actor();
const attempt = await authorize(owner);
const selectionUrl = `${apiOrigin}/api/cloud/github/install-callback?${new URLSearchParams({ state: attempt.state, flow: "select" })}`;
const selection = await app.request(selectionUrl);
expect(selection.status).toBe(200);
expect(await selection.text()).toContain("Acme");
await db.delete(schema.session).where(eq(schema.session.id, owner.sessionId));
expect((await app.request(selectionUrl)).status).toBe(403);
const response = await app.request(
`${apiOrigin}/api/cloud/github/install-callback?${new URLSearchParams({ state: attempt.state, installation_id: "42", setup_action: "install" })}`,
);
@@ -374,6 +379,29 @@ describe("Cloud repository authorization from browser callback through library a
expect(await repos.gitInstallation.listByOrganization(owner.orgId)).toEqual([]);
});
it("completes repository authorization through the public picker without a Cloud session cookie", async () => {
const owner = await actor();
const attempt = await authorize(owner);
const page = await app.request(
`${apiOrigin}/api/cloud/github/install-callback?${new URLSearchParams({ state: attempt.state, flow: "select" })}`,
);
expect(page.status).toBe(200);
const html = await page.text();
expect(html).toContain(`name="state" value="${attempt.state}"`);
expect(html).toContain('name="installation_id" value="42"');
expect(page.headers.get("set-cookie")).toBeNull();
const selected = await app.request(
`${apiOrigin}/api/cloud/github/install-callback?${new URLSearchParams({ state: attempt.state, installation_id: "42", setup_action: "update" })}`,
);
expect(selected.status).toBe(200);
expect(await owner.client.github.pollConnect({ state: attempt.state })).toEqual({
status: "complete",
});
expect((await owner.client.github.getHome()).repos).toMatchObject([
{ full_name: "Acme/private-app" },
]);
});
it("completes OAuth and an installation claim once when callbacks race", async () => {
const owner = await actor();
const attempt = await start(owner);
+9 -2
View File
@@ -104,6 +104,13 @@ The public `GET` handoff routes take query params, not a body — `desktop-hando
expect `redirect`, `state`, and `code_challenge`; `github/install-callback` expects `installation_id`,
`setup_action`, and `state`; `github/oauth-bridge` expects `token`.
GitHub installation links first open Openship's account selection using `flow=select` and
`state`. The user can reconnect an existing installation or continue to GitHub for a new
one. This also works in the external browser used by self-hosted and desktop instances:
the temporary state binds the choice to its initiating user and workspace without requiring
a Cloud browser session. Discovery and selection recheck access, and the installation claim
uses the same verification and transaction as the dashboard picker.
### Deploy proxies (preflight, edge proxy, analytics)
```
@@ -306,8 +313,8 @@ from the dashboard.
| Check edge-proxy domain verification. | `POST /api/cloud/edge-proxy/verify-check`<br />`cloud:write` |
| Exchange a one-time code for user + session (rate-limited). | `POST /api/cloud/exchange-code`<br />`Handler authentication` |
| Export an org- or project-scope subgraph dump (rate-limited). | `POST /api/cloud/export-subgraph`<br />`cloud:admin` |
| GitHub install callback — atomically claim a durable user/workspace nonce after matching user-token and Openship App-JWT verification. | `GET /api/cloud/github/install-callback`<br />`Handler authentication` |
| Build the App install URL with a one-time state token. | `POST /api/cloud/github/install-url`<br />`cloud:write` |
| Select an existing GitHub installation with flow=select, or atomically claim the workspace-bound attempt after GitHub user-token and App-JWT verification. | `GET /api/cloud/github/install-callback`<br />`Handler authentication` |
| Build an Openship installation-selection URL with a one-time workspace state; existing installations can reconnect without another GitHub setup callback. | `POST /api/cloud/github/install-url`<br />`cloud:write` |
| Mint a short-lived (~60min) installation access token. | `POST /api/cloud/github/installation-token`<br />`cloud:write` |
| List the org owner's GitHub App installations. | `GET /api/cloud/github/installations`<br />`cloud:read` |
| Consume the bridge token and redirect the browser into GitHub OAuth. | `GET /api/cloud/github/oauth-bridge`<br />`Handler authentication` |
@@ -15,14 +15,18 @@
*/
import crypto from "node:crypto";
import { cloudRuntimeTarget } from "../../config/env";
import { cloudRuntimeTarget, env } from "../../config/env";
import { repos } from "@repo/db";
import { AppError, safeErrorMessage } from "@repo/core";
import type { GitHubInstallationSelection } from "@repo/contracts";
import * as githubAuth from "../github/github.auth";
import { createEphemeralStore } from "../../lib/ephemeral-store";
import { buildBackgroundContext } from "../../lib/background-context";
import { resolveOrgOwner } from "../../lib/org-actor";
import { verifyGitHubInstallationForUser } from "../github/github.installation-verification";
import {
listGitHubInstallationsForUser,
verifyGitHubInstallationForUser,
} from "../github/github.installation-verification";
import { REPOSITORY_OAUTH_STATE_PREFIX, beginRepositoryAuthorization, repositoryOAuthStateCookie, repositoryOAuthCookieName, assertRepositoryConnectionActor } from "../github/github-repository-authorization";
// ─── OAuth bridge store (shared between handoff + bridge handlers) ──────────
@@ -107,9 +111,84 @@ export async function buildOrgScopedInstallUrl(
organizationId,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
});
const baseUrl = githubAuth.getInstallUrl();
const url = `${baseUrl}?state=${encodeURIComponent(state)}`;
return { url, state };
// GitHub sends an already-installed App to its settings page without a setup
// callback. Start on Openship so the user can explicitly claim that existing
// installation instead of getting stuck waiting for another GitHub install.
const url = new URL("/api/cloud/github/install-callback", cloudRuntimeTarget.api);
url.search = new URLSearchParams({ flow: "select", state }).toString();
return { url: url.toString(), state };
}
export type GithubInstallSelectionResult =
| {
kind: "ready";
state: string;
workspaceName: string;
installUrl: string;
installations: GitHubInstallationSelection["installations"];
}
| { kind: "missing-params" }
| { kind: "state-expired" }
| { kind: "forbidden"; message: string }
| { kind: "failed"; error: string };
/** Offer existing installations without importing them into a workspace.
* Selection uses the same one-shot, user/workspace-bound callback as a new
* GitHub install; the claim re-verifies access after the user makes a choice. */
export async function getGithubInstallSelection(
state: string | undefined,
): Promise<GithubInstallSelectionResult> {
if (!state) return { kind: "missing-params" };
try {
const binding = await repos.githubInstallState.find(state);
if (!binding?.organizationId || binding.sourceId || binding.flow !== "install") {
return { kind: "state-expired" };
}
await assertRepositoryConnectionActor(
binding.userId,
binding.organizationId,
binding.payload.sessionId,
);
const workspace = await repos.organization.findById(binding.organizationId);
if (!workspace) return { kind: "state-expired" };
const appId = Number(env.GITHUB_APP_ID);
if (!Number.isSafeInteger(appId) || appId <= 0) {
throw new Error("The Openship GitHub App is not configured.");
}
const available = await listGitHubInstallationsForUser(binding.userId);
if (!available) {
return {
kind: "forbidden",
message: "GitHub authorization is missing. Start the connection again from Openship.",
};
}
const installUrl = new URL(githubAuth.getInstallUrl());
installUrl.searchParams.set("state", state);
const connected = await repos.gitInstallation.listByOrganization(binding.organizationId);
return {
kind: "ready",
state,
workspaceName: workspace.name,
installUrl: installUrl.toString(),
installations: available
.filter((installation) => installation.app_id === appId && !installation.suspended_at)
.map((installation) => ({
id: installation.id,
login: installation.account.login,
avatarUrl: installation.account.avatar_url,
type: installation.account.type,
connected: connected.some(
(entry) => !entry.sourceId && entry.installationId === installation.id,
),
})),
};
} catch (error) {
if (error instanceof AppError && error.statusCode < 500) {
return { kind: "forbidden", message: error.message };
}
return { kind: "failed", error: safeErrorMessage(error) };
}
}
// ─── Install callback: state-based attribution ───────────────────────────────
@@ -158,37 +158,87 @@ async function installationRedirect(ctx: ExecutionContext) {
};
}
async function connectCloudApp(ctx: ExecutionContext, input: NonNullable<Parameters<GitHubOperations["connect"]>[0]>) {
if (input.source === "cli") throw new AppError("Use a GitHub App or personal token on Openship Cloud.", 400, "NOT_SUPPORTED");
async function connectCloudApp(
ctx: ExecutionContext,
input: NonNullable<Parameters<GitHubOperations["connect"]>[0]>,
) {
if (input.source === "cli")
throw new AppError(
"Use a GitHub App or personal token on Openship Cloud.",
400,
"NOT_SUPPORTED",
);
const status = await githubAuth.getUserStatus(ctx.userId, ctx);
const current = status.connected ? await githubAuth.getUserInstallations(ctx, status) : [];
if (status.connected && current.length && !input.source && !input.state) return { connected: true as const };
let install: { state: string; url: string };
if (status.connected && !input.source && !input.state) {
const current = await githubAuth.getUserInstallations(ctx, status);
if (current.length) return { connected: true as const };
}
let state: string;
if (input.state) {
const binding = await repos.githubInstallState.find(input.state);
if (!binding || binding.flow !== "install" || binding.sourceId || binding.userId !== ctx.userId || binding.organizationId !== ctx.organizationId) {
throw new AppError("This GitHub connection attempt expired or belongs to another workspace. Start again.", 409, "GITHUB_ATTEMPT_EXPIRED");
if (
!binding ||
binding.flow !== "install" ||
binding.sourceId ||
binding.userId !== ctx.userId ||
binding.organizationId !== ctx.organizationId
) {
throw new AppError(
"This GitHub connection attempt expired or belongs to another workspace. Start again.",
409,
"GITHUB_ATTEMPT_EXPIRED",
);
}
install = { state: input.state, url: `${githubAuth.getInstallUrl()}?state=${encodeURIComponent(input.state)}` };
state = input.state;
} else {
const result = await githubAuth.resolveInstallUrl(ctx);
if (!result.state || !result.url) throw new AppError("Could not start GitHub installation. Try again.", 503, "GITHUB_APP_UNAVAILABLE");
install = { state: result.state, url: result.url };
if (!result.state || !result.url)
throw new AppError(
"Could not start GitHub installation. Try again.",
503,
"GITHUB_APP_UNAVAILABLE",
);
state = result.state;
}
if (!status.connected) return {
connected: false as const, flow: "redirect" as const, step: "install" as const, completion: "attempt" as const,
url: `${resolveAuthBaseUrl()}/api/github/connect/redirect?install_state=${encodeURIComponent(install.state)}`, state: install.state,
if (!status.connected)
return {
connected: false as const,
flow: "redirect" as const,
step: "install" as const,
completion: "attempt" as const,
url: `${resolveAuthBaseUrl()}/api/github/connect/redirect?install_state=${encodeURIComponent(state)}`,
state,
};
// Direct Cloud dashboards and external install links share discovery and
// workspace checks. Their browser adapters render the same verified choices.
const { getGithubInstallSelection } = await import("../cloud/cloud-github.service");
const selection = await getGithubInstallSelection(state);
if (selection.kind === "forbidden")
throw new AppError(selection.message, 403, "GITHUB_INSTALLATION_FAILED");
if (selection.kind === "failed")
throw new AppError(selection.error, 502, "GITHUB_INSTALLATION_FAILED");
if (selection.kind !== "ready")
throw new AppError(
"This GitHub connection attempt expired. Start again.",
409,
"GITHUB_ATTEMPT_EXPIRED",
);
if (selection.installations.length)
return {
connected: false,
flow: "installations",
state,
installUrl: selection.installUrl,
installations: selection.installations,
} satisfies GitHubInstallationSelection;
return {
connected: false as const,
flow: "redirect" as const,
step: "install" as const,
completion: "attempt" as const,
state,
url: selection.installUrl,
};
const { listAvailableGitHubInstallations } = await import("./github.installation-verification");
const available = await listAvailableGitHubInstallations(ctx.userId);
if (available.length) return {
connected: false, flow: "installations", state: install.state, installUrl: install.url,
installations: available.map((entry) => ({
id: entry.id, login: entry.account.login, avatarUrl: entry.account.avatar_url, type: entry.account.type,
connected: current.some((item) => item.id === entry.id),
})),
} satisfies GitHubInstallationSelection;
return { connected: false as const, flow: "redirect" as const, step: "install" as const, completion: "attempt" as const, ...install };
}
/** POST /github/connect - Normalized connection flow.
@@ -12,7 +12,6 @@ import { appFetch, getUserToken } from "./github.auth";
import type { GitSource } from "@repo/db";
import { githubAppFetch } from "./github.app-client";
import { sourceClientCredentials } from "./github-source.service";
import { env } from "../../config/env";
import type { GitHubInstallation } from "@repo/contracts";
export type GitHubInstallationVerificationResult =
@@ -23,18 +22,7 @@ export type GitHubInstallationVerificationResult =
message: string;
};
async function findUserInstallation(
token: string,
installationId: number,
): Promise<GitHubInstallation | null> {
for await (const batch of userInstallationPages(token)) {
const match = batch.find((installation) => installation.id === installationId);
if (match) return match;
}
return null;
}
async function* userInstallationPages(token: string): AsyncGenerator<GitHubInstallation[]> {
async function* userInstallations(token: string): AsyncGenerator<GitHubInstallation> {
const perPage = 100;
for (let page = 1; page <= 50; page++) {
const data = await ghFetch<{
@@ -48,7 +36,7 @@ async function* userInstallationPages(token: string): AsyncGenerator<GitHubInsta
throw new Error("GitHub returned an invalid installation count.");
}
const batch = data.installations ?? [];
yield batch;
yield* batch;
if (batch.length < perPage || page * perPage >= data.total_count) {
return;
}
@@ -56,17 +44,28 @@ async function* userInstallationPages(token: string): AsyncGenerator<GitHubInsta
throw new Error("GitHub returned too many installations. Narrow App access on GitHub and try again.");
}
/** Discover through this user's GitHub authorization, never another tenant's DB. */
export async function listAvailableGitHubInstallations(userId: string): Promise<GitHubInstallation[]> {
/** Read GitHub's live catalog for an explicit connection attempt. This must
* never replace the workspace-scoped catalog used by status and repo reads. */
export async function listGitHubInstallationsForUser(
userId: string,
): Promise<GitHubInstallation[] | null> {
const token = await getUserToken(userId);
if (!token) return [];
if (!token) return null;
const installations: GitHubInstallation[] = [];
for await (const batch of userInstallationPages(token)) {
installations.push(...batch.filter((entry) => entry.app_id === Number(env.GITHUB_APP_ID) && !entry.suspended_at));
}
for await (const installation of userInstallations(token)) installations.push(installation);
return installations;
}
async function findUserInstallation(
token: string,
installationId: number,
): Promise<GitHubInstallation | null> {
for await (const installation of userInstallations(token)) {
if (installation.id === installationId) return installation;
}
return null;
}
export async function verifyGitHubInstallationForUser(
userId: string,
installationId: number,
+2 -2
View File
@@ -2864,11 +2864,11 @@
},
"GET /api/cloud/github/install-callback": {
"page": "cloud",
"description": "GitHub install callback — atomically claim a durable user/workspace nonce after matching user-token and Openship App-JWT verification."
"description": "Select an existing GitHub installation with flow=select, or atomically claim the workspace-bound attempt after GitHub user-token and App-JWT verification."
},
"POST /api/cloud/github/install-url": {
"page": "cloud",
"description": "Build the App install URL with a one-time state token."
"description": "Build an Openship installation-selection URL with a one-time workspace state; existing installations can reconnect without another GitHub setup callback."
},
"POST /api/cloud/github/installation-token": {
"page": "cloud",