diff --git a/apps/api/src/services/git-token-service.ts b/apps/api/src/services/git-token-service.ts index d6d418b4..bf284c78 100644 --- a/apps/api/src/services/git-token-service.ts +++ b/apps/api/src/services/git-token-service.ts @@ -13,7 +13,7 @@ export interface GitTokenContext { /** * Resolve a git platform token for the given platform and context. - * GitHub: delegates to the existing github-token-service (user OAuth → App → PAT). + * GitHub: delegates to the existing github-token-service (App → user OAuth → PAT). * GitLab: checks GITLAB_TOKEN secret (workspace-scoped → global). */ export async function getGitToken( diff --git a/apps/api/src/services/github-token-service.test.ts b/apps/api/src/services/github-token-service.test.ts index a1443ce9..7dc2b32f 100644 --- a/apps/api/src/services/github-token-service.test.ts +++ b/apps/api/src/services/github-token-service.test.ts @@ -405,69 +405,4 @@ describe("github-token-service", () => { expect(mockDeleteSecret).toHaveBeenCalledWith("GITHUB_USER_REFRESH_TOKEN", "user:user-7"); expect(mockDeleteSecret).toHaveBeenCalledWith("GITHUB_USER_TOKEN_EXPIRES_AT", "user:user-7"); }); - - it("falls back to installation token when user has no OAuth and GitHub App is configured", async () => { - mockRetrieveSecret.mockRejectedValue(new Error("Secret not found")); - mockIsConfigured.mockReturnValue(true); - mockGetInstToken.mockResolvedValue("ghs_install_token"); - - const token = await getGitHubToken({ userId: "user-google" }); - - expect(token).toBe("ghs_install_token"); - expect(mockGetInstToken).toHaveBeenCalled(); - expect(mockRetrieveSecretWithFallback).not.toHaveBeenCalled(); - }); - - it("falls back to PAT when user has no OAuth, App is configured but installation token fails", async () => { - mockRetrieveSecret.mockRejectedValue(new Error("Secret not found")); - mockIsConfigured.mockReturnValue(true); - mockGetInstToken.mockRejectedValue(new Error("Installation 404")); - mockRetrieveSecretWithFallback.mockResolvedValue("ghp_pat_token"); - - const token = await getGitHubToken({ userId: "user-google", workspaceId: "ws-x" }); - - expect(token).toBe("ghp_pat_token"); - expect(mockRetrieveSecretWithFallback).toHaveBeenCalledWith("GITHUB_TOKEN", "global", "ws-x"); - }); - - it("throws when user has no OAuth, no GitHub App, and no PAT", async () => { - mockRetrieveSecret.mockRejectedValue(new Error("Secret not found")); - mockIsConfigured.mockReturnValue(false); - mockRetrieveSecretWithFallback.mockRejectedValue(new Error("Secret not found")); - - await expect(getGitHubToken({ userId: "user-x" })).rejects.toThrow("No GitHub token available"); - }); - - it("uses installation token for task-scoped lookup when task creator has no OAuth", async () => { - mockDbWhere.mockResolvedValue([{ createdBy: "user-google", workspaceId: "ws-3" }]); - mockRetrieveSecret.mockRejectedValue(new Error("Secret not found")); - mockIsConfigured.mockReturnValue(true); - mockGetInstToken.mockResolvedValue("ghs_task_install_token"); - - const token = await getGitHubToken({ taskId: "task-with-google-creator" }); - - expect(token).toBe("ghs_task_install_token"); - }); - - it("falls back to installation token when token refresh fails and App is configured", async () => { - const pastDate = new Date(Date.now() - 60 * 1000).toISOString(); - mockRetrieveSecret - .mockResolvedValueOnce("ghu_expired") - .mockResolvedValueOnce(pastDate) - .mockResolvedValueOnce("ghr_refresh"); - - process.env.GITHUB_APP_CLIENT_ID = "client-id"; - process.env.GITHUB_APP_CLIENT_SECRET = "client-secret"; - - const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 }); - globalThis.fetch = mockFetch; - - mockIsConfigured.mockReturnValue(true); - mockGetInstToken.mockResolvedValue("ghs_post_refresh_install"); - - const token = await getGitHubToken({ userId: "user-refresh-fail" }); - - expect(token).toBe("ghs_post_refresh_install"); - expect(mockGetInstToken).toHaveBeenCalled(); - }); }); diff --git a/apps/api/src/services/github-token-service.ts b/apps/api/src/services/github-token-service.ts index 42546c8d..cceb9f70 100644 --- a/apps/api/src/services/github-token-service.ts +++ b/apps/api/src/services/github-token-service.ts @@ -16,14 +16,26 @@ const TOKEN_REFRESH_BUFFER_MS = 10 * 60 * 1000; export type GitHubTokenContext = | { taskId: string } | { userId: string; workspaceId?: string | null } - | { server: true }; + | { server: true; workspaceId?: string | null }; export async function getGitHubToken(context: GitHubTokenContext): Promise { - if ("server" in context) return fallbackToAppOrPat(); + if ("server" in context) return getServerToken(context.workspaceId); if ("taskId" in context) return getTokenForTask(context.taskId); return getTokenForUser(context.userId, context.workspaceId); } +async function getServerToken(workspaceId?: string | null): Promise { + if (isGitHubAppConfigured()) { + try { + return await getInstallationToken(); + } catch (err) { + logger.warn({ err }, "Installation token failed, falling back to PAT"); + return getPatFallback(workspaceId); + } + } + return getPatFallback(workspaceId); +} + async function getTokenForTask(taskId: string): Promise { const [task] = await db .select({ createdBy: tasks.createdBy, workspaceId: tasks.workspaceId }) @@ -32,7 +44,7 @@ async function getTokenForTask(taskId: string): Promise { if (!task?.createdBy) { // No user associated — use server/installation token (e.g., system-created tasks) - return fallbackToAppOrPat(); + return getServerToken(task?.workspaceId); } return getTokenForUser(task.createdBy, task.workspaceId); } @@ -48,8 +60,8 @@ async function getTokenForUser(userId: string, workspaceId?: string | null): Pro } return refreshUserToken(userId, workspaceId); } catch (err) { - logger.warn({ userId, err }, "No stored user token, falling back to App/PAT"); - return fallbackToAppOrPat(workspaceId); + logger.warn({ userId, err }, "No stored user token, falling back to server token"); + return getServerToken(workspaceId); } } @@ -72,7 +84,7 @@ async function doRefreshUserToken(userId: string, workspaceId?: string | null): if (!clientId || !clientSecret) { await deleteUserGitHubTokens(userId); - return fallbackToAppOrPat(workspaceId); + return getServerToken(workspaceId); } try { @@ -116,27 +128,16 @@ async function doRefreshUserToken(userId: string, workspaceId?: string | null): } catch (err) { // Don't delete tokens on transient errors (network, 5xx) — only the // definitive revocation cases above delete them before re-throwing. - logger.warn({ userId, err }, "Token refresh failed, falling back to App/PAT"); - return fallbackToAppOrPat(workspaceId); + logger.warn({ userId, err }, "Token refresh failed, falling back to server token"); + return getServerToken(workspaceId); } } /** - * Fallback chain when no user OAuth token is available: - * GitHub App installation token → GITHUB_TOKEN PAT → throw. - * - * Used by both user-scoped lookups (user signed in via a non-GitHub provider, - * or OAuth missing/unrefreshable) and server-scoped lookups (system-created - * tasks, PR watcher, reconciler). + * Last-resort fallback: try to retrieve a manually-configured GITHUB_TOKEN PAT. + * Returns the token if found, throws a descriptive error if not. */ -async function fallbackToAppOrPat(workspaceId?: string | null): Promise { - if (isGitHubAppConfigured()) { - try { - return await getInstallationToken(); - } catch (err) { - logger.warn({ err }, "Installation token failed, falling back to PAT"); - } - } +async function getPatFallback(workspaceId?: string | null): Promise { try { return await retrieveSecretWithFallback("GITHUB_TOKEN", "global", workspaceId); } catch {