Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/services/git-token-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ export async function getGitToken(
context: GitTokenContext,
): Promise<string> {
if (platform === "github") {
if (context.server) return getGitHubToken({ server: true });
if (context.server) return getGitHubToken({ server: true, workspaceId: context.workspaceId });
if (context.userId)
return getGitHubToken({ userId: context.userId, workspaceId: context.workspaceId });
return getGitHubToken({ server: true });
return getGitHubToken({ server: true, workspaceId: context.workspaceId });
}

// GitLab: try user-scoped token, then workspace/global GITLAB_TOKEN
Expand Down
36 changes: 34 additions & 2 deletions apps/api/src/services/github-token-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,16 @@ describe("github-token-service", () => {
db: {
select: () => ({
from: () => ({
where: (...args: unknown[]) => mockDbWhere(...args),
where: () => {
const res = {
limit: (...args: unknown[]) => mockDbWhere(...args),
// Handle direct execution after where() without limit()
then: (cb: any) => mockDbWhere().then(cb),
};
// @ts-expect-error Mocking async iterator for Drizzle query result
res[Symbol.iterator] = [][Symbol.iterator];
return res;
},
}),
}),
},
Expand All @@ -228,6 +237,11 @@ describe("github-token-service", () => {
createdBy: "created_by",
workspaceId: "workspace_id",
},
secrets: {
id: "id",
name: "name",
workspaceId: "workspace_id",
},
}));

vi.doMock("./secret-service.js", () => ({
Expand Down Expand Up @@ -335,7 +349,7 @@ describe("github-token-service", () => {
});

it("resolves task creator's token", async () => {
mockDbWhere.mockResolvedValue([{ createdBy: "user-5", workspaceId: "ws-2" }]);
mockDbWhere.mockResolvedValueOnce([{ createdBy: "user-5", workspaceId: "ws-2" }]);
const futureDate = new Date(Date.now() + 60 * 60 * 1000).toISOString();
mockRetrieveSecret
.mockResolvedValueOnce("ghu_task_user_token")
Expand All @@ -360,6 +374,8 @@ describe("github-token-service", () => {

it("falls back to PAT when GitHub App not configured (server context)", async () => {
mockIsConfigured.mockReturnValue(false);
// Return no token found by the new fallback logic
mockDbWhere.mockResolvedValueOnce([]);
mockRetrieveSecretWithFallback.mockResolvedValue("ghp_server_pat");

const token = await getGitHubToken({ server: true });
Expand All @@ -372,6 +388,22 @@ describe("github-token-service", () => {
);
});

it("falls back to any available PAT when GitHub App not configured (server context, no workspaceId)", async () => {
mockIsConfigured.mockReturnValue(false);
// Simulate finding an existing token in a different workspace
mockDbWhere.mockResolvedValueOnce([{ workspaceId: "ws-other" }]);
mockRetrieveSecretWithFallback.mockResolvedValue("ghp_other_pat");

const token = await getGitHubToken({ server: true });

expect(token).toBe("ghp_other_pat");
expect(mockRetrieveSecretWithFallback).toHaveBeenCalledWith(
"GITHUB_TOKEN",
"global",
"ws-other",
);
});

it("storeUserGitHubTokens stores 3 secrets", async () => {
await storeUserGitHubTokens("user-6", {
accessToken: "ghu_access",
Expand Down
28 changes: 22 additions & 6 deletions apps/api/src/services/github-token-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { tasks } from "../db/schema.js";
import { tasks, secrets } from "../db/schema.js";
import {
retrieveSecret,
retrieveSecretWithFallback,
Expand All @@ -16,24 +16,40 @@ 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<string> {
if ("server" in context) return getServerToken();
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(): Promise<string> {
async function getServerToken(workspaceId?: string | null): Promise<string> {
if (isGitHubAppConfigured()) {
try {
return await getInstallationToken();
} catch (err) {
logger.warn({ err }, "Installation token failed, falling back to PAT");
return getPatFallback();
return getPatFallback(workspaceId);
}
}
return getPatFallback();
// If no GitHub App, and no workspace context provided (e.g. repo-init),
// try to find ANY global GITHUB_TOKEN to use as a server-level fallback.
// This handles the case where a token exists but is scoped to a workspace,
// preventing AAD decryption failures during system-level clones.
if (!workspaceId) {
const [anyGlobalToken] = await db
.select({ workspaceId: secrets.workspaceId })
.from(secrets)
.where(eq(secrets.name, "GITHUB_TOKEN"))
.limit(1);

if (anyGlobalToken) {
return getPatFallback(anyGlobalToken.workspaceId);
}
}

return getPatFallback(workspaceId);
}

async function getTokenForTask(taskId: string): Promise<string> {
Expand Down
Loading