mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-24 08:11:53 +02:00
feat: GitHub App installation auth for GitHub sources (#457)
Adds GitHub App installation as an authentication option for GitHub sources, with installation token refresh for Gitea 1.27+ pull mirrors, credential redaction, and the account filters applied to installation listings.
This commit is contained in:
@@ -22,6 +22,12 @@ Picking GitLab or Gitea/Forgejo shows an **Instance URL** field. Leave it empty
|
||||
|
||||
The **Test** button on the card checks the token against the selected host.
|
||||
|
||||
For a GitHub source, **Authentication** can also be set to **GitHub App installation**. Create a GitHub App with repository **Contents** and **Metadata** read permissions; grant **Issues** and **Pull requests** read permissions if mirroring that metadata. Install it on the repositories to back up, enter the installation account or organization under **Username**, then enter its App ID, installation ID and PEM private key in the Sources card. The private key is encrypted at rest and is not returned by the source API. The App sees only repositories selected for its installation; it cannot discover a user's starred repositories or star lists. PAT authentication remains available for those workflows.
|
||||
|
||||
App installation tokens expire after an hour, so Gitea Mirror stores a fresh one on the mirror before each of its own scheduled or manual syncs and turns off Gitea's independent pull schedule for App-backed repositories. Keep the Gitea Mirror scheduler enabled for ongoing App-backed mirrors. Existing PAT mirrors continue using their usual interval.
|
||||
|
||||
Replacing a mirror's credentials needs Gitea 1.27 or later as the destination. Gitea 1.26 and earlier, and Forgejo, ignore those fields, so App-backed repositories are refused there with an error instead of being mirrored and failing once the first token expires. Use a personal access token for those destinations. GitHub and GitLab destinations clone with a fresh token on every push and work with any version.
|
||||
|
||||
## What is mirrored
|
||||
|
||||
The mirror itself is done by Gitea's pull mirror, which treats every source as a plain git remote. The differences are in what the source API offers.
|
||||
|
||||
@@ -133,7 +133,13 @@ export function ConfigTabs() {
|
||||
// githubConfig check only covers APIs that predate multi-source support.
|
||||
const isSourceConnected = (): boolean => {
|
||||
if (sources.length > 0) {
|
||||
return sources.some((source) => Boolean(source.token && source.token.trim()));
|
||||
return sources.some((source) => {
|
||||
if (!source.enabled) return false;
|
||||
if (source.authType === 'app') {
|
||||
return Boolean(source.appId?.trim() && source.installationId?.trim());
|
||||
}
|
||||
return Boolean(source.token && source.token.trim());
|
||||
});
|
||||
}
|
||||
const { githubConfig } = config;
|
||||
return !!(githubConfig.username.trim() && githubConfig.token.trim());
|
||||
|
||||
@@ -178,6 +178,10 @@ type EditorState = {
|
||||
url: string;
|
||||
username: string;
|
||||
token: string;
|
||||
authType: "pat" | "app";
|
||||
appId: string;
|
||||
installationId: string;
|
||||
privateKey: string;
|
||||
/** Set once the lock dialog confirmed changing provider or instance URL. */
|
||||
sourceUnlocked: boolean;
|
||||
};
|
||||
@@ -210,8 +214,9 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
// An empty token field while editing keeps the stored one, so testing
|
||||
// and saving both fall back to it.
|
||||
const effectiveToken = editor?.token.trim() || editing?.token || "";
|
||||
const isApp = provider === "github" && editor?.authType === "app";
|
||||
const isPublicOnlyDraft =
|
||||
!!editor && !editor.username.trim() && !effectiveToken;
|
||||
!!editor && !editor.username.trim() && !effectiveToken && !isApp;
|
||||
|
||||
const refreshSources = async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -243,6 +248,10 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
url: "",
|
||||
username: "",
|
||||
token: "",
|
||||
authType: "pat",
|
||||
appId: "",
|
||||
installationId: "",
|
||||
privateKey: "",
|
||||
sourceUnlocked: false,
|
||||
});
|
||||
};
|
||||
@@ -254,6 +263,10 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
url: source.url ?? "",
|
||||
username: source.username ?? "",
|
||||
token: "",
|
||||
authType: source.authType ?? "pat",
|
||||
appId: source.appId ?? "",
|
||||
installationId: source.installationId ?? "",
|
||||
privateKey: "",
|
||||
sourceUnlocked: false,
|
||||
});
|
||||
};
|
||||
@@ -276,7 +289,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!editor) return;
|
||||
if (!effectiveToken) {
|
||||
if (!effectiveToken && !isApp) {
|
||||
toast.error(`${providerLabel} token is required to test the connection`);
|
||||
return;
|
||||
}
|
||||
@@ -284,7 +297,14 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
setIsTesting(true);
|
||||
|
||||
try {
|
||||
const result = await githubApi.testConnection(effectiveToken, {
|
||||
const result = isApp ? await (async () => {
|
||||
const response = await fetch(withBase("/api/github/test-connection"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, url: editor.url, username: editor.username.trim(), authType: "app", appId: editor.appId, installationId: editor.installationId, privateKey: editor.privateKey, sourceId: editing?.id }),
|
||||
});
|
||||
return response.json();
|
||||
})() : await githubApi.testConnection(effectiveToken, {
|
||||
provider,
|
||||
url: editor.url.trim() || undefined,
|
||||
username: editor.username.trim() || undefined,
|
||||
@@ -314,13 +334,25 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
provider,
|
||||
username: editor.username.trim(),
|
||||
};
|
||||
if (provider === "github") {
|
||||
body.authType = editor.authType;
|
||||
if (isApp) {
|
||||
body.appId = editor.appId;
|
||||
body.installationId = editor.installationId;
|
||||
body.privateKey = editor.privateKey;
|
||||
}
|
||||
}
|
||||
if (editor.url.trim()) {
|
||||
body.url = editor.url.trim();
|
||||
}
|
||||
// Empty token on edit keeps the stored one (existing save convention).
|
||||
if (!editing || editor.token.trim()) {
|
||||
if (!isApp && (!editing || editor.token.trim())) {
|
||||
body.token = editor.token.trim();
|
||||
}
|
||||
if (editing?.authType === "app" && !isApp && !editor.token.trim()) {
|
||||
toast.error("Enter a token to stop using the GitHub App credentials");
|
||||
return;
|
||||
}
|
||||
if (editing && (confirmSourceChange || editor.sourceUnlocked)) {
|
||||
body.confirmSourceChange = true;
|
||||
}
|
||||
@@ -505,7 +537,27 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{provider === "github" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="source-auth-type" className="text-xs font-medium text-muted-foreground">Authentication</Label>
|
||||
<Select value={editor.authType} onValueChange={(authType: "pat" | "app") => setEditor((prev) => prev ? { ...prev, authType } : prev)}>
|
||||
<SelectTrigger id="source-auth-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pat">Personal access token</SelectItem>
|
||||
<SelectItem value="app">GitHub App installation</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isApp ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5"><Label htmlFor="source-app-id">App ID</Label><Input id="source-app-id" name="appId" inputMode="numeric" value={editor.appId} onChange={handleFieldChange} /></div>
|
||||
<div className="space-y-1.5"><Label htmlFor="source-installation-id">Installation ID</Label><Input id="source-installation-id" name="installationId" inputMode="numeric" value={editor.installationId} onChange={handleFieldChange} /></div>
|
||||
<div className="space-y-1.5"><Label htmlFor="source-private-key">Private key (PEM)</Label><textarea id="source-private-key" name="privateKey" value={editor.privateKey} onChange={(event) => setEditor((prev) => prev ? { ...prev, privateKey: event.target.value } : prev)} placeholder={editing?.authType === "app" ? "Leave empty to keep the current key" : "-----BEGIN PRIVATE KEY-----"} className="min-h-28 w-full rounded-md border border-input bg-background p-2 font-mono text-xs" /></div>
|
||||
<p className="text-[11px] text-muted-foreground">Install the App on repositories to mirror. Grant repository Contents read access, and Issues and Metadata read access for metadata mirroring. GitHub Apps cannot mirror a user's starred repositories.</p>
|
||||
</div>
|
||||
) : <div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="source-token"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
@@ -527,9 +579,9 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
<p className="text-[11px] text-muted-foreground/80">
|
||||
{providerMeta.tokenHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="space-y-3 rounded-lg bg-muted/40 p-4">
|
||||
{!isApp && <div className="space-y-3 rounded-lg bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -563,7 +615,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
@@ -571,7 +623,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting || !effectiveToken}
|
||||
disabled={isTesting || (!effectiveToken && !isApp)}
|
||||
>
|
||||
<PlugZap className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isTesting ? "Testing..." : "Test"}
|
||||
@@ -591,7 +643,7 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
size="sm"
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={
|
||||
isSaving || (!editor.username.trim() && !!editor.token.trim())
|
||||
isSaving || (isApp ? !editor.appId.trim() || !editor.installationId.trim() || (!editor.privateKey.trim() && editing?.authType !== "app") : !editor.username.trim() && !!editor.token.trim())
|
||||
}
|
||||
>
|
||||
{isSaving ? "Saving..." : editing ? "Save changes" : "Add source"}
|
||||
@@ -638,7 +690,8 @@ export function SourcesCard({ sources, onRefresh }: SourcesCardProps) {
|
||||
{meta.badge}
|
||||
</span>
|
||||
)}
|
||||
{source.token === "" && (
|
||||
{source.authType === "app" && <span className="text-[11px] text-muted-foreground">GitHub App</span>}
|
||||
{source.token === "" && source.authType !== "app" && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Globe className="h-3 w-3" />
|
||||
Public only
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import type { ConfigApiResponse } from '@/types/config';
|
||||
import { hasConfiguredSource } from './useConfigStatus';
|
||||
|
||||
test('App sources keep the dashboard configured when the private key is redacted', () => {
|
||||
const config = {
|
||||
githubConfig: { token: '' },
|
||||
sources: [{ provider: 'github', authType: 'app', token: '' }],
|
||||
} as ConfigApiResponse;
|
||||
expect(hasConfiguredSource(config)).toBe(true);
|
||||
expect(hasConfiguredSource({ ...config, sources: [{ provider: 'github', authType: 'pat', token: '' }] } as ConfigApiResponse)).toBe(false);
|
||||
expect(hasConfiguredSource({ ...config, githubConfig: { token: 'ghp_pat' }, sources: [] } as ConfigApiResponse)).toBe(true);
|
||||
});
|
||||
@@ -42,6 +42,13 @@ const cacheSubscribers = new Set<() => void>();
|
||||
// of them at once, and without this each would fire its own request.
|
||||
let inFlightRequest: { userId: string; promise: Promise<ConfigApiResponse> } | null = null;
|
||||
|
||||
export function hasConfiguredSource(configResponse: ConfigApiResponse | null): boolean {
|
||||
return Boolean(
|
||||
configResponse?.githubConfig?.token ||
|
||||
configResponse?.sources?.some(source => source.provider === 'github' && source.authType === 'app')
|
||||
);
|
||||
}
|
||||
|
||||
function fetchConfigOnce(userId: string): Promise<ConfigApiResponse> {
|
||||
if (inFlightRequest && inFlightRequest.userId === userId) {
|
||||
return inFlightRequest.promise;
|
||||
@@ -107,13 +114,9 @@ export function useConfigStatus(): ConfigStatus {
|
||||
if (isCacheValid && hasCheckedRef.current) {
|
||||
const configResponse = configCache.data!;
|
||||
|
||||
// Only token/url are actually required at runtime: the GitHub token is
|
||||
// self-authenticating for listForAuthenticatedUser, and a Gitea username
|
||||
// isn't needed under single-org / flat mirror strategies. Users who
|
||||
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
|
||||
// (or who otherwise left those blank) were being locked out of the
|
||||
// dashboard even though mirroring worked fine (see issue #271).
|
||||
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
|
||||
// App private keys are redacted from the config response, so connected
|
||||
// App sources count alongside a visible PAT for dashboard access.
|
||||
const isGitHubConfigured = hasConfiguredSource(configResponse);
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
@@ -158,13 +161,8 @@ export function useConfigStatus(): ConfigStatus {
|
||||
userId: user.id
|
||||
};
|
||||
|
||||
// Only token/url are actually required at runtime: the GitHub token is
|
||||
// self-authenticating for listForAuthenticatedUser, and a Gitea username
|
||||
// isn't needed under single-org / flat mirror strategies. Users who
|
||||
// configure via env vars without GITHUB_USERNAME / GITEA_USERNAME set
|
||||
// (or who otherwise left those blank) were being locked out of the
|
||||
// dashboard even though mirroring worked fine (see issue #271).
|
||||
const isGitHubConfigured = !!configResponse?.githubConfig?.token;
|
||||
// Match the cached path: a redacted App source is still configured.
|
||||
const isGitHubConfigured = hasConfiguredSource(configResponse);
|
||||
|
||||
const isGiteaConfigured = !!(
|
||||
configResponse?.giteaConfig?.url &&
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { createMockResponse, mockFetch } from "@/tests/mock-fetch";
|
||||
import { getRepositorySource } from "@/lib/source-providers/kinds";
|
||||
import { encodeGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { clearDestinationVersionCache } from "@/lib/gitea-mirror-credentials";
|
||||
|
||||
// Mock the helpers module before importing gitea-enhanced
|
||||
const mockCreateMirrorJob = mock(() => Promise.resolve("mock-job-id"));
|
||||
@@ -102,6 +104,9 @@ mock.module("@/lib/sources", () => ({
|
||||
);
|
||||
},
|
||||
decryptSourceToken: (token: string | null | undefined) => token ?? "",
|
||||
sourceAccessToken: async (source: { token?: string | null }) => source.token?.startsWith("github-app:v1:") ? "ghs_fresh" : source.token ?? "",
|
||||
isGitHubAppSource: (source: { provider?: string; token?: string | null }) =>
|
||||
source.provider === "github" && !!source.token?.startsWith("github-app:v1:"),
|
||||
resolveGitHubApiBaseUrl: (url: string | null | undefined) => {
|
||||
const trimmed = url?.trim().replace(/\/+$/, "") ?? "";
|
||||
if (!trimmed || trimmed === "https://github.com") return undefined;
|
||||
@@ -123,8 +128,14 @@ let orgTestContext = "";
|
||||
let getOrgCalled = false;
|
||||
let createOrgCalled = false;
|
||||
|
||||
// The destination version, for the GitHub App credential refresh check.
|
||||
let giteaVersion = "1.27.0";
|
||||
|
||||
const mockHttpGet = mock(async (url: string, headers?: any) => {
|
||||
// Return different responses based on URL patterns
|
||||
if (url.endsWith("/api/v1/version")) {
|
||||
return { data: { version: giteaVersion }, status: 200, statusText: "OK", headers: new Headers() };
|
||||
}
|
||||
|
||||
// Handle user authentication endpoint
|
||||
if (url.includes("/api/v1/user")) {
|
||||
@@ -972,6 +983,82 @@ describe("Enhanced Gitea Operations", () => {
|
||||
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
|
||||
});
|
||||
|
||||
describe("GitHub App sources", () => {
|
||||
const appConfig = {
|
||||
userId: "user123",
|
||||
githubConfig: { username: "testuser", token: "", privateRepositories: false, mirrorStarred: false },
|
||||
giteaConfig: { url: "https://gitea.example.com", token: "encrypted-token", defaultOwner: "testuser", mirrorReleases: false, mirrorInterval: "8h" },
|
||||
} as unknown as Partial<Config>;
|
||||
const appRepository = () => ({
|
||||
id: "repo-1", sourceId: "source-1", name: "test-repo", fullName: "ceph/test-repo",
|
||||
owner: "ceph", cloneUrl: "https://github.com/ceph/test-repo.git", isPrivate: false,
|
||||
isStarred: false, status: repoStatusEnum.parse("mirrored"), visibility: "public",
|
||||
userId: "user123", mirroredLocation: "starred/test-repo", createdAt: new Date(), updatedAt: new Date(),
|
||||
} as Repository);
|
||||
// Injected so the sync makes no GitHub calls with the fake token.
|
||||
const appDependencies = {
|
||||
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
|
||||
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
|
||||
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
|
||||
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
|
||||
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
|
||||
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
|
||||
syncRepositoryMetadataToGitea: mockSyncRepositoryMetadataToGitea,
|
||||
};
|
||||
let originalToken: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalToken = sourceRows[0].token;
|
||||
sourceRows[0].token = encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----" });
|
||||
clearDestinationVersionCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sourceRows[0].token = originalToken;
|
||||
giteaVersion = "1.27.0";
|
||||
clearDestinationVersionCache();
|
||||
});
|
||||
|
||||
test("refreshes the mirror credentials before triggering a sync", async () => {
|
||||
await syncGiteaRepoEnhanced({ config: appConfig, repository: appRepository() }, appDependencies);
|
||||
const repoPatches = mockHttpPatch.mock.calls.filter(
|
||||
(call) => call[0] === "https://gitea.example.com/api/v1/repos/starred/test-repo"
|
||||
);
|
||||
// Only the credential refresh: the configured 8h interval is not
|
||||
// re-applied, since Gitea would then pull with an expired token.
|
||||
expect(repoPatches).toEqual([
|
||||
[
|
||||
"https://gitea.example.com/api/v1/repos/starred/test-repo",
|
||||
{ mirror_username: "x-access-token", mirror_token: "ghs_fresh", mirror_interval: "0h" },
|
||||
{ Authorization: "token encrypted-token" },
|
||||
],
|
||||
]);
|
||||
expect(mockHttpPost.mock.calls.some((call) => String(call[0]).includes("/mirror-sync"))).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts the Gitea 28+ version line", async () => {
|
||||
giteaVersion = "29.0.0+dev-160-g55186ca39b";
|
||||
await syncGiteaRepoEnhanced({ config: appConfig, repository: appRepository() }, appDependencies);
|
||||
expect(mockHttpPost.mock.calls.some((call) => String(call[0]).includes("/mirror-sync"))).toBe(true);
|
||||
});
|
||||
|
||||
test("refuses to sync on Gitea 1.26, which ignores mirror credentials", async () => {
|
||||
giteaVersion = "1.26.1";
|
||||
await expect(
|
||||
syncGiteaRepoEnhanced({ config: appConfig, repository: appRepository() }, appDependencies)
|
||||
).rejects.toThrow("GitHub App sources need Gitea 1.27 or later");
|
||||
expect(mockHttpPost.mock.calls.some((call) => String(call[0]).includes("/mirror-sync"))).toBe(false);
|
||||
});
|
||||
|
||||
test("refuses to sync on Forgejo", async () => {
|
||||
giteaVersion = "11.0.3+gitea-1.22.0";
|
||||
await expect(
|
||||
syncGiteaRepoEnhanced({ config: appConfig, repository: appRepository() }, appDependencies)
|
||||
).rejects.toThrow("GitHub App sources need Gitea 1.27 or later");
|
||||
expect(mockHttpPost.mock.calls.some((call) => String(call[0]).includes("/mirror-sync"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("finds a mirror transferred to another owner through the search and syncs it there (#400)", async () => {
|
||||
const config = {
|
||||
userId: "user123",
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
} from "@/lib/source-providers/kinds";
|
||||
import { sourceConnectionFromSource } from "@/lib/source-providers";
|
||||
import { refreshMirrorCredentials } from "./gitea-mirror-credentials";
|
||||
import {
|
||||
decryptSourceToken,
|
||||
findSourceForRepository,
|
||||
isGitHubAppSource,
|
||||
listSources,
|
||||
sourceAccessToken,
|
||||
resolveGitHubApiBaseUrl,
|
||||
} from "@/lib/sources";
|
||||
import type { Repository } from "./db/schema";
|
||||
@@ -483,7 +485,8 @@ export async function syncGiteaRepoEnhanced({
|
||||
// every GitHub client below.
|
||||
const sources = config.userId ? await listSources(config.userId) : [];
|
||||
const repoSource = findSourceForRepository(repository, sources);
|
||||
const repoSourceToken = repoSource ? decryptSourceToken(repoSource.token) : "";
|
||||
const repoUsesGitHubApp = repoSource ? isGitHubAppSource(repoSource) : false;
|
||||
const repoSourceToken = repoSource ? await sourceAccessToken(repoSource) : "";
|
||||
const repoSourceKind = getRepositorySource(repository).provider;
|
||||
const repoIsGitHub = repoSourceKind === "github";
|
||||
|
||||
@@ -904,16 +907,18 @@ export async function syncGiteaRepoEnhanced({
|
||||
}
|
||||
|
||||
// Update mirror interval if needed
|
||||
// NOTE: Gitea/Forgejo's PATCH /repos/{owner}/{repo} API does not support
|
||||
// updating mirror credentials (mirror_username/mirror_password). Repos that
|
||||
// were originally migrated without credentials must be deleted and re-mirrored.
|
||||
// NOTE: Gitea before 1.27 and Forgejo ignore mirror credentials
|
||||
// (mirror_username/mirror_password) on PATCH /repos/{owner}/{repo}. Repos
|
||||
// that were migrated without credentials must be deleted and re-mirrored
|
||||
// there; GitHub App repos, which need a new token every sync, are refused
|
||||
// on those destinations (gitea-mirror-credentials.ts).
|
||||
//
|
||||
// Skipped for archived repos: archiveGiteaRepo deliberately disabled
|
||||
// Gitea's own periodic pulling, and the documented contract
|
||||
// (AutomationSettings.tsx: "Archive ... disables automatic syncs—use
|
||||
// Manual Sync when you want to refresh") says a Manual Sync refreshes
|
||||
// once without re-enabling any automatic syncing.
|
||||
if (config.giteaConfig?.mirrorInterval && !isArchivedRepo) {
|
||||
if (config.giteaConfig?.mirrorInterval && !isArchivedRepo && !repoUsesGitHubApp) {
|
||||
try {
|
||||
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
|
||||
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
|
||||
@@ -929,6 +934,18 @@ export async function syncGiteaRepoEnhanced({
|
||||
}
|
||||
}
|
||||
|
||||
// A GitHub App installation token expires within an hour: hand Gitea a
|
||||
// fresh one with enough headroom for its queued pull, before the sync.
|
||||
if (repoUsesGitHubApp && repoSource) {
|
||||
await refreshMirrorCredentials({
|
||||
config,
|
||||
giteaToken: decryptedConfig.giteaConfig.token,
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
accessToken: await sourceAccessToken(repoSource, { minValidityMs: 30 * 60_000 }),
|
||||
});
|
||||
}
|
||||
|
||||
// Perform the sync
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/mirror-sync`;
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { supportsMirrorCredentialUpdate } from "./gitea-mirror-credentials";
|
||||
|
||||
describe("supportsMirrorCredentialUpdate", () => {
|
||||
test("accepts Gitea 1.27 and the 28+ version line", () => {
|
||||
for (const version of ["1.27.0", "1.27.3", "v1.27.1", "1.28.0", "28.2.0", "29.0.0+dev-160-g55186ca39b"]) {
|
||||
expect(supportsMirrorCredentialUpdate(version)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects Gitea 1.26 and earlier, which drop the mirror credential fields", () => {
|
||||
for (const version of ["1.26.1", "1.24.7", "1.20.0"]) {
|
||||
expect(supportsMirrorCredentialUpdate(version)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects Forgejo whatever its own version number", () => {
|
||||
for (const version of ["11.0.3+gitea-1.22.0", "16.0.0-dev-785-465b60ef+gitea-1.22.0"]) {
|
||||
expect(supportsMirrorCredentialUpdate(version)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a missing or unparsable version", () => {
|
||||
expect(supportsMirrorCredentialUpdate("")).toBe(false);
|
||||
expect(supportsMirrorCredentialUpdate("development")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Replacing the source credentials a Gitea pull mirror stores.
|
||||
*
|
||||
* GitHub App installation tokens expire after an hour, so an App-backed pull
|
||||
* mirror only keeps pulling if its credentials are replaced before each sync.
|
||||
* Gitea accepts mirror_username, mirror_password and mirror_token on
|
||||
* PATCH /repos/{owner}/{repo} from 1.27.0 (and on the 28+ line). Gitea 1.26
|
||||
* and earlier, and Forgejo, drop those fields without an error, so the
|
||||
* destination version is checked before an App-backed repository is
|
||||
* migrated or synced.
|
||||
*/
|
||||
import type { Config } from "@/types/config";
|
||||
import { resolveDestinationKind } from "./destination-connection";
|
||||
import { httpGet, httpPatch } from "./http-client";
|
||||
|
||||
const VERSION_CACHE_MS = 10 * 60 * 1000;
|
||||
const versionCache = new Map<string, { version: string; fetchedAt: number }>();
|
||||
|
||||
/** True when a Gitea version string accepts mirror credential updates. */
|
||||
export function supportsMirrorCredentialUpdate(version: string): boolean {
|
||||
// Forgejo reports e.g. "11.0.3+gitea-1.22.0".
|
||||
if (/\+gitea-/i.test(version) || /forgejo/i.test(version)) return false;
|
||||
const match = /^v?(\d+)\.(\d+)/.exec(version.trim());
|
||||
if (!match) return false;
|
||||
const major = Number(match[1]);
|
||||
const minor = Number(match[2]);
|
||||
return major > 1 || (major === 1 && minor >= 27);
|
||||
}
|
||||
|
||||
async function destinationVersion(baseUrl: string, token: string): Promise<string> {
|
||||
const cached = versionCache.get(baseUrl);
|
||||
if (cached && Date.now() - cached.fetchedAt < VERSION_CACHE_MS) return cached.version;
|
||||
const response = await httpGet<{ version?: string }>(`${baseUrl}/api/v1/version`, {
|
||||
Authorization: `token ${token}`,
|
||||
});
|
||||
const version = String(response.data?.version ?? "");
|
||||
versionCache.set(baseUrl, { version, fetchedAt: Date.now() });
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when the destination cannot refresh mirror credentials, before any
|
||||
* mirror is created that would stop pulling once its first token expires.
|
||||
*/
|
||||
export async function assertMirrorCredentialUpdateSupported(
|
||||
config: Partial<Config>,
|
||||
giteaToken: string
|
||||
): Promise<void> {
|
||||
const unsupported =
|
||||
"GitHub App sources need Gitea 1.27 or later as the destination, because installation tokens expire " +
|
||||
"after an hour and older Gitea and Forgejo cannot update a mirror's credentials through the API. " +
|
||||
"Use a personal access token for this source instead.";
|
||||
if (resolveDestinationKind(config) === "forgejo") throw new Error(unsupported);
|
||||
const baseUrl = (config.giteaConfig?.url ?? "").replace(/\/+$/, "");
|
||||
const version = await destinationVersion(baseUrl, giteaToken);
|
||||
if (!supportsMirrorCredentialUpdate(version)) {
|
||||
throw new Error(`${unsupported} (destination reports version ${version || "unknown"})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Store a fresh installation token on the mirror and keep Gitea's own schedule off. */
|
||||
export async function refreshMirrorCredentials({
|
||||
config,
|
||||
giteaToken,
|
||||
owner,
|
||||
repo,
|
||||
accessToken,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
giteaToken: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
accessToken: string;
|
||||
}): Promise<void> {
|
||||
await assertMirrorCredentialUpdateSupported(config, giteaToken);
|
||||
const baseUrl = (config.giteaConfig?.url ?? "").replace(/\/+$/, "");
|
||||
await httpPatch(
|
||||
`${baseUrl}/api/v1/repos/${owner}/${repo}`,
|
||||
{
|
||||
mirror_username: "x-access-token",
|
||||
mirror_token: accessToken,
|
||||
// Gitea's own pulls would run with an expired token; this app's
|
||||
// scheduler refreshes the token and triggers each sync instead.
|
||||
mirror_interval: "0h",
|
||||
},
|
||||
{ Authorization: `token ${giteaToken}` }
|
||||
);
|
||||
}
|
||||
|
||||
/** Test hook: forget cached destination versions. */
|
||||
export function clearDestinationVersionCache(): void {
|
||||
versionCache.clear();
|
||||
}
|
||||
@@ -182,6 +182,8 @@ mock.module("@/lib/sources", () => ({
|
||||
findSourceForOrganization: (org: { sourceId?: string | null }, list: any[]) =>
|
||||
org.sourceId ? list.find((source: any) => source.id === org.sourceId) ?? null : null,
|
||||
decryptSourceToken: (token: string | null | undefined) => token ?? "",
|
||||
sourceAccessToken: async (source: { token?: string | null }) => source.token ?? "",
|
||||
isGitHubAppSource: () => false,
|
||||
resolveGitHubApiBaseUrl: (url: string | null | undefined) => {
|
||||
const trimmed = url?.trim().replace(/\/+$/, "") ?? "";
|
||||
if (!trimmed || trimmed === "https://github.com") return undefined;
|
||||
|
||||
+27
-6
@@ -27,9 +27,12 @@ import {
|
||||
decryptSourceToken,
|
||||
findSourceForOrganization,
|
||||
findSourceForRepository,
|
||||
isGitHubAppSource,
|
||||
listSources,
|
||||
sourceAccessToken,
|
||||
} from "./sources";
|
||||
import { isRateLimitError } from "./rate-limit-gate";
|
||||
import { assertMirrorCredentialUpdateSupported } from "./gitea-mirror-credentials";
|
||||
import { persistMetadataPassProgress } from "./metadata-progress-store";
|
||||
import {
|
||||
advanceMetadataSyncCursor,
|
||||
@@ -774,7 +777,14 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
const sourceConnection = repoSource
|
||||
? sourceConnectionFromSource(repoSource, { userId: config.userId })
|
||||
: resolveSourceConnection(config);
|
||||
const repoSourceToken = repoSource ? decryptSourceToken(repoSource.token) : "";
|
||||
// A GitHub App installation token expires within an hour, so the mirror
|
||||
// can only keep pulling where Gitea lets each sync replace it.
|
||||
const repoUsesGitHubApp = repoSource ? isGitHubAppSource(repoSource) : false;
|
||||
if (repoUsesGitHubApp) {
|
||||
await assertMirrorCredentialUpdateSupported(config, decryptedConfig.giteaConfig.token);
|
||||
}
|
||||
const repoSourceToken = repoSource ? await sourceAccessToken(repoSource) : "";
|
||||
sourceConnection.token = repoSourceToken;
|
||||
|
||||
// Get the correct owner based on the strategy (with organization overrides)
|
||||
let repoOwner = await getGiteaRepoOwnerAsync({
|
||||
@@ -1085,7 +1095,9 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
clone_addr: cloneAddress,
|
||||
repo_name: targetRepoName,
|
||||
mirror: true,
|
||||
mirror_interval: config.giteaConfig?.mirrorInterval || "8h",
|
||||
// App-backed mirrors are pulled only when this app has refreshed the
|
||||
// token; Gitea's own schedule would pull with an expired one.
|
||||
mirror_interval: repoUsesGitHubApp ? "0h" : config.giteaConfig?.mirrorInterval || "8h",
|
||||
wiki: mirrorOptions.wiki,
|
||||
// Gitea performs the LFS fetch inside its own migration, so a failure
|
||||
// here aborts the entire migration with nothing to salvage. That is why
|
||||
@@ -1107,7 +1119,7 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
buildSourceAuthPayload({
|
||||
provider: sourceConnection.provider,
|
||||
token: repoSourceToken,
|
||||
username: sourceConnection.username,
|
||||
username: repoUsesGitHubApp ? "x-access-token" : sourceConnection.username,
|
||||
repositoryOwner: repository.owner,
|
||||
})
|
||||
);
|
||||
@@ -1680,7 +1692,14 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
const sourceConnection = repoSource
|
||||
? sourceConnectionFromSource(repoSource, { userId: config.userId })
|
||||
: resolveSourceConnection(config);
|
||||
const repoSourceToken = repoSource ? decryptSourceToken(repoSource.token) : "";
|
||||
// A GitHub App installation token expires within an hour, so the mirror
|
||||
// can only keep pulling where Gitea lets each sync replace it.
|
||||
const repoUsesGitHubApp = repoSource ? isGitHubAppSource(repoSource) : false;
|
||||
if (repoUsesGitHubApp) {
|
||||
await assertMirrorCredentialUpdateSupported(config, decryptedConfig.giteaConfig.token);
|
||||
}
|
||||
const repoSourceToken = repoSource ? await sourceAccessToken(repoSource) : "";
|
||||
sourceConnection.token = repoSourceToken;
|
||||
|
||||
// Determine the actual repository name to use (handle duplicates for starred repos)
|
||||
let targetRepoName = repository.name;
|
||||
@@ -1900,7 +1919,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
uid: giteaOrgId,
|
||||
repo_name: targetRepoName,
|
||||
mirror: true,
|
||||
mirror_interval: config.giteaConfig?.mirrorInterval || "8h",
|
||||
// App-backed mirrors are pulled only when this app has refreshed the
|
||||
// token; Gitea's own schedule would pull with an expired one.
|
||||
mirror_interval: repoUsesGitHubApp ? "0h" : config.giteaConfig?.mirrorInterval || "8h",
|
||||
wiki: mirrorOptions.wiki,
|
||||
// Gitea performs the LFS fetch inside its own migration, so a failure
|
||||
// here aborts the entire migration with nothing to salvage. That is why
|
||||
@@ -1921,7 +1942,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
buildSourceAuthPayload({
|
||||
provider: sourceConnection.provider,
|
||||
token: repoSourceToken,
|
||||
username: sourceConnection.username,
|
||||
username: repoUsesGitHubApp ? "x-access-token" : sourceConnection.username,
|
||||
repositoryOwner: repository.owner,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Octokit for a GitHub App installation source.
|
||||
*
|
||||
* The client is the throttled public one with an installation token added to
|
||||
* every request. Tokens come from resolveGitHubAccessToken, which caches them
|
||||
* until shortly before they expire, so long runs keep working past the hour.
|
||||
*/
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createPublicGitHubClient } from "@/lib/github";
|
||||
import { resolveGitHubAccessToken } from "@/lib/github-app-credentials";
|
||||
|
||||
export function createGitHubAppClient(
|
||||
credential: string,
|
||||
{ userId, apiBaseUrl }: { userId?: string; apiBaseUrl?: string } = {}
|
||||
): Octokit {
|
||||
const octokit = createPublicGitHubClient(apiBaseUrl, userId);
|
||||
const tokenApiBaseUrl =
|
||||
apiBaseUrl || process.env.GH_API_URL || process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
octokit.hook.before("request", async (options) => {
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
authorization: `token ${await resolveGitHubAccessToken(credential, tokenApiBaseUrl)}`,
|
||||
};
|
||||
});
|
||||
return octokit;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { encodeGitHubAppCredentials, parseGitHubAppCredentials, resolveGitHubAccessToken } from "./github-app-credentials";
|
||||
|
||||
test("GitHub App credentials round-trip without confusing PATs", () => {
|
||||
const credentials = { appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----" };
|
||||
expect(parseGitHubAppCredentials(encodeGitHubAppCredentials(credentials))).toEqual(credentials);
|
||||
expect(parseGitHubAppCredentials("ghp_pat")).toBeNull();
|
||||
expect(() => parseGitHubAppCredentials("github-app:v1:broken")).toThrow();
|
||||
});
|
||||
|
||||
test("installation tokens are minted, cached and never substituted for a PAT", async () => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } });
|
||||
const credential = encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requests = 0;
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
requests++;
|
||||
expect(String(input)).toBe("https://api.github.com/app/installations/456/access_tokens");
|
||||
expect(init?.headers).toHaveProperty("Authorization");
|
||||
return new Response(JSON.stringify({ token: "ghs_test", expires_at: new Date(Date.now() + 3600_000).toISOString() }), { status: 201 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
expect(await resolveGitHubAccessToken(credential)).toBe("ghs_test");
|
||||
expect(await resolveGitHubAccessToken(credential)).toBe("ghs_test");
|
||||
expect(requests).toBe(1);
|
||||
expect(await resolveGitHubAccessToken("ghp_pat")).toBe("ghp_pat");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { sign } from "jsonwebtoken";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export interface GitHubAppCredentials {
|
||||
appId: string;
|
||||
installationId: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
const PREFIX = "github-app:v1:";
|
||||
|
||||
/** True for a stored App credential, valid or not; never send one as a token. */
|
||||
export function isGitHubAppCredential(value: string | null | undefined): boolean {
|
||||
return typeof value === "string" && value.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
export function encodeGitHubAppCredentials(credentials: GitHubAppCredentials): string {
|
||||
if (!/^\d+$/.test(credentials.appId) || !/^\d+$/.test(credentials.installationId) ||
|
||||
!credentials.privateKey.includes("-----BEGIN") || !credentials.privateKey.includes("PRIVATE KEY-----")) {
|
||||
throw new Error("A GitHub App ID, installation ID, and PEM private key are required.");
|
||||
}
|
||||
return PREFIX + JSON.stringify(credentials);
|
||||
}
|
||||
|
||||
export function parseGitHubAppCredentials(value: string): GitHubAppCredentials | null {
|
||||
if (!value.startsWith(PREFIX)) return null;
|
||||
try {
|
||||
const credentials = JSON.parse(value.slice(PREFIX.length));
|
||||
if (typeof credentials.appId === "string" && typeof credentials.installationId === "string" &&
|
||||
typeof credentials.privateKey === "string") {
|
||||
encodeGitHubAppCredentials(credentials);
|
||||
return credentials;
|
||||
}
|
||||
} catch {
|
||||
// Invalid stored credentials should never be used as an access token.
|
||||
}
|
||||
throw new Error("Invalid stored GitHub App credentials.");
|
||||
}
|
||||
|
||||
const installationTokens = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
/**
|
||||
* The token to authenticate with: a PAT as is, or an installation token for
|
||||
* App credentials. `minValidityMs` is how long a cached installation token
|
||||
* must still be valid; a token handed to Gitea for a queued mirror pull needs
|
||||
* more headroom than one used for an API call made right away.
|
||||
*/
|
||||
export async function resolveGitHubAccessToken(
|
||||
value: string,
|
||||
apiBaseUrl = "https://api.github.com",
|
||||
{ minValidityMs = 60_000 }: { minValidityMs?: number } = {}
|
||||
): Promise<string> {
|
||||
const credentials = parseGitHubAppCredentials(value);
|
||||
if (!credentials) return value;
|
||||
|
||||
const cacheKey = createHash("sha256").update(`${apiBaseUrl}:${credentials.appId}:${credentials.installationId}:${credentials.privateKey}`).digest("hex");
|
||||
const cached = installationTokens.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now() + minValidityMs) return cached.token;
|
||||
|
||||
const jwt = sign({}, credentials.privateKey, {
|
||||
algorithm: "RS256",
|
||||
issuer: credentials.appId,
|
||||
expiresIn: "9m",
|
||||
});
|
||||
const response = await fetch(`${apiBaseUrl.replace(/\/+$/, "")}/app/installations/${credentials.installationId}/access_tokens`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitHub App installation token request failed (${response.status}).`);
|
||||
const result = await response.json() as { token?: string; expires_at?: string };
|
||||
if (!result.token || !result.expires_at || !Number.isFinite(Date.parse(result.expires_at))) {
|
||||
throw new Error("GitHub App returned an invalid installation token.");
|
||||
}
|
||||
installationTokens.set(cacheKey, { token: result.token, expiresAt: Date.parse(result.expires_at) });
|
||||
return result.token;
|
||||
}
|
||||
+65
-38
@@ -334,7 +334,7 @@ export function createGitHubClient(
|
||||
* schedule retries. Never pass an empty token to createGitHubClient
|
||||
* instead — Octokit treats `auth: ""` as an invalid credential header.
|
||||
*/
|
||||
export function createPublicGitHubClient(apiBaseUrl?: string): Octokit {
|
||||
export function createPublicGitHubClient(apiBaseUrl?: string, userId?: string): Octokit {
|
||||
// Same base URL resolution as createGitHubClient: an explicit apiBaseUrl
|
||||
// (a GitHub Enterprise source's /api/v3) replaces the env default;
|
||||
// otherwise support GH_API_URL (preferred) or GITHUB_API_URL (may conflict
|
||||
@@ -356,13 +356,13 @@ export function createPublicGitHubClient(apiBaseUrl?: string): Octokit {
|
||||
"x-github-api-version": "2022-11-28", // Use a stable API version
|
||||
},
|
||||
},
|
||||
throttle: githubThrottleOptions(),
|
||||
throttle: githubThrottleOptions(userId),
|
||||
});
|
||||
|
||||
// Anonymous clients share one gate key, so one exhausted 60 req/hr budget
|
||||
// pauses every tokenless source together.
|
||||
installRateLimitGate(octokit, {
|
||||
key: rateLimitGateKey(undefined),
|
||||
key: rateLimitGateKey(userId),
|
||||
maxWaitMs: RATE_LIMIT_FAST_FAIL_THRESHOLD_SECONDS * 1000,
|
||||
});
|
||||
|
||||
@@ -392,6 +392,63 @@ export async function getGithubRepoCloneUrl({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The account filters a repository listing honors: skipForks (with
|
||||
* per-organization pins), skipPersonalRepos and the includeOrganizations
|
||||
* allowlist. Shared by the token listing and the GitHub App installation
|
||||
* listing so both import the same repositories.
|
||||
*/
|
||||
export function filterGithubRepositories<
|
||||
T extends { fork?: boolean; owner: { login: string; type?: string } },
|
||||
>(
|
||||
repos: T[],
|
||||
config: Partial<Config>,
|
||||
{
|
||||
includeAllOrgsOverride,
|
||||
orgForkOverrides,
|
||||
authenticatedUserLogin,
|
||||
}: {
|
||||
includeAllOrgsOverride?: boolean;
|
||||
orgForkOverrides?: Map<string, boolean>;
|
||||
authenticatedUserLogin: string;
|
||||
}
|
||||
): T[] {
|
||||
const forkSkipFor = orgForkSkipDecision(orgForkOverrides, config);
|
||||
const skipPersonalRepos = config.githubConfig?.skipPersonalRepos ?? false;
|
||||
// Opt-in organization allowlist. When non-empty, only repos owned by the
|
||||
// listed organizations are imported; org repos from any other org the user
|
||||
// happens to be a member of are dropped. Empty = import all org repos
|
||||
// (backward-compatible default). Owned/collaborator repos are unaffected.
|
||||
const includeOrgs = includeAllOrgsOverride
|
||||
? []
|
||||
: config.githubConfig?.includeOrganizations ?? [];
|
||||
const allowedOrgs = new Set(
|
||||
includeOrgs.map((org) => org.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
return repos.filter((repo) => {
|
||||
const skipForks = forkSkipFor(
|
||||
repo.owner.type === "Organization" ? repo.owner.login : undefined
|
||||
);
|
||||
const isForkAllowed = !skipForks || !repo.fork;
|
||||
// When skipPersonalRepos is true, drop repos owned by the authenticated user
|
||||
// (owner.type === "User" and owner.login matches the configured GitHub username).
|
||||
// Org repos have owner.type === "Organization" so they are always kept.
|
||||
const isPersonalRepo =
|
||||
skipPersonalRepos &&
|
||||
authenticatedUserLogin.length > 0 &&
|
||||
repo.owner.login === authenticatedUserLogin &&
|
||||
repo.owner.type === "User";
|
||||
// When an allowlist is configured, only keep org repos whose owning org
|
||||
// is listed. Non-org repos (owned/collaborator) are never restricted here.
|
||||
const isOrgAllowed =
|
||||
allowedOrgs.size === 0 ||
|
||||
repo.owner.type !== "Organization" ||
|
||||
allowedOrgs.has(repo.owner.login.toLowerCase());
|
||||
return isForkAllowed && !isPersonalRepo && isOrgAllowed;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user repositories from GitHub
|
||||
* todo: need to handle pagination and apply more filters based on user config
|
||||
@@ -435,41 +492,11 @@ export async function getGithubRepositories({
|
||||
{ per_page: 100, affiliation },
|
||||
);
|
||||
|
||||
const forkSkipFor = orgForkSkipDecision(orgForkOverrides, config);
|
||||
const skipPersonalRepos = config.githubConfig?.skipPersonalRepos ?? false;
|
||||
// The authenticated user's login — used to identify personally-owned repos
|
||||
const authenticatedUserLogin = config.githubConfig?.owner ?? "";
|
||||
// Opt-in organization allowlist. When non-empty, only repos owned by the
|
||||
// listed organizations are imported; org repos from any other org the user
|
||||
// happens to be a member of are dropped. Empty = import all org repos
|
||||
// (backward-compatible default). Owned/collaborator repos are unaffected.
|
||||
const includeOrgs = includeAllOrgsOverride
|
||||
? []
|
||||
: config.githubConfig?.includeOrganizations ?? [];
|
||||
const allowedOrgs = new Set(
|
||||
includeOrgs.map((org) => org.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
const filteredRepos = repos.filter((repo) => {
|
||||
const skipForks = forkSkipFor(
|
||||
repo.owner.type === "Organization" ? repo.owner.login : undefined
|
||||
);
|
||||
const isForkAllowed = !skipForks || !repo.fork;
|
||||
// When skipPersonalRepos is true, drop repos owned by the authenticated user
|
||||
// (owner.type === "User" and owner.login matches the configured GitHub username).
|
||||
// Org repos have owner.type === "Organization" so they are always kept.
|
||||
const isPersonalRepo =
|
||||
skipPersonalRepos &&
|
||||
authenticatedUserLogin.length > 0 &&
|
||||
repo.owner.login === authenticatedUserLogin &&
|
||||
repo.owner.type === "User";
|
||||
// When an allowlist is configured, only keep org repos whose owning org
|
||||
// is listed. Non-org repos (owned/collaborator) are never restricted here.
|
||||
const isOrgAllowed =
|
||||
allowedOrgs.size === 0 ||
|
||||
repo.owner.type !== "Organization" ||
|
||||
allowedOrgs.has(repo.owner.login.toLowerCase());
|
||||
return isForkAllowed && !isPersonalRepo && isOrgAllowed;
|
||||
const filteredRepos = filterGithubRepositories(repos, config, {
|
||||
includeAllOrgsOverride,
|
||||
orgForkOverrides,
|
||||
// The authenticated user's login — used to identify personally-owned repos
|
||||
authenticatedUserLogin: config.githubConfig?.owner ?? "",
|
||||
});
|
||||
|
||||
return filteredRepos.map((repo) => ({
|
||||
|
||||
@@ -17,6 +17,8 @@ import { db, organizations, repositories } from "@/lib/db";
|
||||
import type { Config, Repository } from "@/lib/db/schema";
|
||||
import { usesPushEngine } from "@/lib/destination-connection";
|
||||
import { createGitHubClient, createPublicGitHubClient } from "@/lib/github";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import { createMirrorJob } from "@/lib/helpers";
|
||||
import {
|
||||
decryptSourceToken,
|
||||
@@ -190,6 +192,9 @@ function createRepositoryOctokitResolver(
|
||||
if (!source || source.provider !== "github") return null;
|
||||
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (isGitHubAppCredential(token)) {
|
||||
return createGitHubAppClient(token, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) });
|
||||
}
|
||||
// A tokenless GitHub source still gets an anonymous public client
|
||||
// (60 req/hr) so public-repo metadata mirrors; an empty token must never
|
||||
// reach createGitHubClient, which would send `auth: ""`.
|
||||
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
decryptSourceToken,
|
||||
findSourceForRepository,
|
||||
listSources,
|
||||
sourceAccessToken,
|
||||
} from "@/lib/sources";
|
||||
import { buildSourceAuthPayload } from "@/lib/utils/mirror-source-auth";
|
||||
import { parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import {
|
||||
assertRepositoryMatchesConfiguredDestination,
|
||||
createPushTargetFromConfig,
|
||||
@@ -58,18 +60,18 @@ export function parseMirroredLocation(location: string | null | undefined): { ow
|
||||
* own source row. Sources are loaded once per push by the caller and passed
|
||||
* down so batches never query per repository.
|
||||
*/
|
||||
export function resolveSourceCredentials(
|
||||
export async function resolveSourceCredentials(
|
||||
config: Partial<Config>,
|
||||
repository: Repository,
|
||||
sources: SourceRecord[]
|
||||
): GitCredentials | null {
|
||||
): Promise<GitCredentials | null> {
|
||||
const source = findSourceForRepository(repository, sources);
|
||||
if (!source) return null;
|
||||
const connection = sourceConnectionFromSource(source, { userId: config.userId });
|
||||
const payload = buildSourceAuthPayload({
|
||||
provider: connection.provider,
|
||||
token: decryptSourceToken(source.token),
|
||||
username: connection.username,
|
||||
token: await sourceAccessToken(source),
|
||||
username: source.provider === "github" && parseGitHubAppCredentials(connection.token) ? "x-access-token" : connection.username,
|
||||
repositoryOwner: repository.owner,
|
||||
});
|
||||
if (!("auth_username" in payload) || !payload.auth_password) return null;
|
||||
@@ -148,7 +150,7 @@ export async function pushMirrorRepository({
|
||||
const outcome = await runPushMirror({
|
||||
clonePath: clonePathForRepository(repository),
|
||||
sourceCloneUrl: repository.cloneUrl,
|
||||
sourceCredentials: resolveSourceCredentials(config, repository, sources),
|
||||
sourceCredentials: await resolveSourceCredentials(config, repository, sources),
|
||||
target: target ?? createPushTargetFromConfig(config),
|
||||
owner,
|
||||
name,
|
||||
|
||||
+8
-1
@@ -9,6 +9,8 @@ import { db, repositories, organizations, mirrorJobs, configs } from './db';
|
||||
import { eq, and, lt, inArray, sql } from 'drizzle-orm';
|
||||
import { mirrorRepositoryToDestination, syncRepositoryOnDestination } from './mirror-dispatch';
|
||||
import { createGitHubClient, createPublicGitHubClient } from './github';
|
||||
import { createGitHubAppClient } from './github-app-client';
|
||||
import { isGitHubAppCredential } from './github-app-credentials';
|
||||
import type { Octokit } from '@octokit/rest';
|
||||
import {
|
||||
decryptSourceToken,
|
||||
@@ -305,7 +307,12 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
|
||||
// (60 req/hr), the same resolution the job routes use; an empty
|
||||
// token must never reach createGitHubClient, which would send
|
||||
// `auth: ""` and 401 on every call.
|
||||
octokit = decryptedToken
|
||||
octokit = isGitHubAppCredential(decryptedToken)
|
||||
? createGitHubAppClient(decryptedToken, {
|
||||
userId: config.userId || undefined,
|
||||
apiBaseUrl: resolveGitHubApiBaseUrl(repoSource.url),
|
||||
})
|
||||
: decryptedToken
|
||||
? createGitHubClient(
|
||||
decryptedToken,
|
||||
config.userId || undefined,
|
||||
|
||||
@@ -410,6 +410,8 @@ async function createOctokitResolverForSources(
|
||||
): Promise<(repo: { sourceId?: string | null }) => Promise<Octokit | null>> {
|
||||
const { decryptSourceToken, resolveGitHubApiBaseUrl } = await import('@/lib/sources');
|
||||
const { createGitHubClient, createPublicGitHubClient } = await import('@/lib/github');
|
||||
const { createGitHubAppClient } = await import('@/lib/github-app-client');
|
||||
const { isGitHubAppCredential } = await import('@/lib/github-app-credentials');
|
||||
|
||||
const sourcesById = new Map(sources.map(source => [source.id, source]));
|
||||
const clientsBySourceId = new Map<string, Octokit | null>();
|
||||
@@ -427,7 +429,9 @@ async function createOctokitResolverForSources(
|
||||
const sourceToken = decryptSourceToken(source.token);
|
||||
clientsBySourceId.set(
|
||||
source.id,
|
||||
sourceToken
|
||||
isGitHubAppCredential(sourceToken)
|
||||
? createGitHubAppClient(sourceToken, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) })
|
||||
: sourceToken
|
||||
? createGitHubClient(
|
||||
sourceToken,
|
||||
userId,
|
||||
@@ -446,7 +450,13 @@ async function createOctokitResolverForSources(
|
||||
if (configClient === undefined) {
|
||||
const { resolveSourceProviderKind } = await import('@/lib/source-providers');
|
||||
if (resolveSourceProviderKind(config) === 'github') {
|
||||
configClient = createGitHubClient(getDecryptedGitHubToken(config), userId, config.githubConfig?.owner);
|
||||
const configToken = getDecryptedGitHubToken(config);
|
||||
configClient = isGitHubAppCredential(configToken)
|
||||
? createGitHubAppClient(configToken, {
|
||||
userId,
|
||||
apiBaseUrl: resolveGitHubApiBaseUrl(config.githubConfig?.url),
|
||||
})
|
||||
: createGitHubClient(configToken, userId, config.githubConfig?.owner);
|
||||
} else {
|
||||
configClient = null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { GitHubSourceProvider, type GithubRestRepository } from "./github-source";
|
||||
import { encodeGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
|
||||
type ListForOrgParams = { org: string; type: "public" | "private" | "member"; per_page: number };
|
||||
type OrgReposPage = (GithubRestRepository & { id: number })[];
|
||||
@@ -35,6 +37,70 @@ const connection = {
|
||||
userId: "u1",
|
||||
};
|
||||
|
||||
test("GitHub App lists installed repositories without using user or starred APIs", async () => {
|
||||
const provider = new GitHubSourceProvider({ ...connection, token: encodeGitHubAppCredentials({
|
||||
appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
|
||||
}) });
|
||||
const request = mock(async (route: string) => {
|
||||
expect(route).toBe("GET /installation/repositories");
|
||||
return { data: { total_count: 1, repositories: [repo(1, "private-tool")] } };
|
||||
});
|
||||
Object.defineProperty(provider, "octokit", { value: { request } });
|
||||
const repos = await provider.listRepositories({});
|
||||
expect(repos.map((item) => item.fullName)).toEqual(["acme/private-tool"]);
|
||||
expect(await provider.listStarredRepositories({})).toEqual([]);
|
||||
});
|
||||
|
||||
test("GitHub App listing applies the same account filters as the token listing", async () => {
|
||||
const provider = new GitHubSourceProvider({ ...connection, token: encodeGitHubAppCredentials({
|
||||
appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
|
||||
}) });
|
||||
const personal = { ...repo(4, "dotfiles", "me"), owner: { login: "me", type: "User" } };
|
||||
const repositories = [
|
||||
repo(1, "tool"),
|
||||
{ ...repo(2, "forked"), fork: true },
|
||||
repo(3, "other-tool", "other-org"),
|
||||
personal,
|
||||
];
|
||||
const request = mock(async () => ({ data: { total_count: repositories.length, repositories } }));
|
||||
Object.defineProperty(provider, "octokit", { value: { request } });
|
||||
const config = {
|
||||
githubConfig: { skipForks: true, skipPersonalRepos: true, includeOrganizations: ["acme"] },
|
||||
} as any;
|
||||
|
||||
expect((await provider.listRepositories(config)).map((item) => item.fullName)).toEqual(["acme/tool"]);
|
||||
// Cleanup lists past the allowlist, like the token path.
|
||||
expect(
|
||||
(await provider.listRepositories(config, { includeAllOrgsOverride: true })).map((item) => item.fullName)
|
||||
).toEqual(["acme/tool", "other-org/other-tool"]);
|
||||
// Every installation org is discovered; the allowlist only narrows imports.
|
||||
const { organizations } = await provider.listOrganizations(config);
|
||||
expect(organizations.map((org) => org.name).sort()).toEqual(["acme", "other-org"]);
|
||||
});
|
||||
|
||||
test("GitHub App Octokit authenticates requests with an installation token", async () => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } });
|
||||
const provider = new GitHubSourceProvider({ ...connection, token: encodeGitHubAppCredentials({ appId: "789", installationId: "987", privateKey }) });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: string[] = [];
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = String(input);
|
||||
requests.push(url);
|
||||
if (url.endsWith("/access_tokens")) {
|
||||
return new Response(JSON.stringify({ token: "ghs_live", expires_at: new Date(Date.now() + 3600_000).toISOString() }), { status: 201, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe("token ghs_live");
|
||||
return new Response(JSON.stringify({ total_count: 1, repositories: [repo(1, "private-tool")] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
expect(await provider.testConnection()).toEqual({ login: "me" });
|
||||
expect((await provider.listRepositories({})).map((item) => item.fullName)).toEqual(["acme/private-tool"]);
|
||||
expect(requests).toHaveLength(3);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
function installOctokitDouble(
|
||||
provider: GitHubSourceProvider,
|
||||
paginate: (fn: unknown, params: ListForOrgParams) => Promise<OrgReposPage>
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
*/
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { Config } from "@/lib/db/schema";
|
||||
import { parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import {
|
||||
createGitHubClient,
|
||||
createPublicGitHubClient,
|
||||
filterGithubRepositories,
|
||||
getGithubOrganizations,
|
||||
getGithubRepositories,
|
||||
getGithubStarredRepositories,
|
||||
@@ -117,9 +120,12 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
// A GitHub Enterprise source URL drives the API base; github.com (the
|
||||
// default) keeps the GH_API_URL env default.
|
||||
const apiBaseUrl = resolveGitHubApiBaseUrl(connection.url);
|
||||
const app = parseGitHubAppCredentials(connection.token);
|
||||
// Tokenless: the throttled public client, never an empty token — anonymous
|
||||
// GitHub runs at 60 req/hr, so the onRateLimit backoff must stay attached.
|
||||
this.octokit = connection.token.trim()
|
||||
this.octokit = app
|
||||
? createGitHubAppClient(connection.token, { userId: connection.userId, apiBaseUrl })
|
||||
: connection.token.trim()
|
||||
? createGitHubClient(
|
||||
connection.token,
|
||||
connection.userId,
|
||||
@@ -141,6 +147,24 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
config: Partial<Config>,
|
||||
options: ListRepositoriesOptions = {}
|
||||
): Promise<GitRepo[]> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) {
|
||||
const repos: GithubRestRepository[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data } = await this.octokit.request("GET /installation/repositories", { per_page: 100, page });
|
||||
repos.push(...data.repositories as GithubRestRepository[]);
|
||||
if (repos.length >= data.total_count || data.repositories.length === 0) break;
|
||||
page++;
|
||||
}
|
||||
// Same account filters as the token listing. The installation account
|
||||
// (the source's username) is the one whose personal repos are skipped.
|
||||
const filtered = filterGithubRepositories(repos, config, {
|
||||
includeAllOrgsOverride: options.includeAllOrgsOverride,
|
||||
orgForkOverrides: options.orgForkOverrides,
|
||||
authenticatedUserLogin: this.connection.username || config.githubConfig?.owner || "",
|
||||
});
|
||||
return this.stamp(filtered.map((repo) => mapGithubRestRepository(repo, this.connection)));
|
||||
}
|
||||
const repos = await getGithubRepositories({
|
||||
octokit: this.octokit,
|
||||
config,
|
||||
@@ -152,6 +176,7 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
}
|
||||
|
||||
async listStarredRepositories(config: Partial<Config>): Promise<GitRepo[]> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) return [];
|
||||
const repos = await getGithubStarredRepositories({ octokit: this.octokit, config });
|
||||
return this.stamp(repos);
|
||||
}
|
||||
@@ -160,6 +185,16 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
config: Partial<Config>,
|
||||
skipOrgNames?: Set<string>
|
||||
): Promise<SourceOrganizationResult> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) {
|
||||
// Every installation org is listed, like the token path's org listing;
|
||||
// the allowlist only narrows which repositories get imported.
|
||||
return this.listRepositories(config, { includeAllOrgsOverride: true }).then((repos) => ({
|
||||
organizations: [...new Set(repos.map((repo) => repo.organization).filter((name): name is string => !!name))]
|
||||
.filter((name) => !skipOrgNames?.has(name))
|
||||
.map((name) => ({ name, avatarUrl: "", membershipRole: "member" as const, isIncluded: false, status: "imported" as const, repositoryCount: repos.filter((repo) => repo.organization === name).length, createdAt: new Date(), updatedAt: new Date() })),
|
||||
failedOrgs: [],
|
||||
}));
|
||||
}
|
||||
return getGithubOrganizations({ octokit: this.octokit, config, skipOrgNames });
|
||||
}
|
||||
|
||||
@@ -232,6 +267,7 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
}
|
||||
|
||||
async isRepositoryStarred(owner: string, name: string): Promise<boolean> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) return false;
|
||||
try {
|
||||
await this.octokit.rest.activity.checkRepoIsStarredByAuthenticatedUser({
|
||||
owner,
|
||||
@@ -245,6 +281,10 @@ export class GitHubSourceProvider implements SourceProvider {
|
||||
}
|
||||
|
||||
async testConnection(): Promise<SourceAccount> {
|
||||
if (parseGitHubAppCredentials(this.connection.token)) {
|
||||
await this.octokit.request("GET /installation/repositories", { per_page: 1 });
|
||||
return { login: this.connection.username };
|
||||
}
|
||||
const { data } = await this.octokit.users.getAuthenticated();
|
||||
return { login: data.login, name: data.name, avatarUrl: data.avatar_url };
|
||||
}
|
||||
|
||||
+20
-1
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import type { SourceProviderKind } from "@/lib/source-providers/kinds";
|
||||
import { isGitHubAppCredential, parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
|
||||
/** One source as the API returns it: the stored row plus its lock state. */
|
||||
export interface SourceApiItem {
|
||||
@@ -18,6 +19,9 @@ export interface SourceApiItem {
|
||||
url: string;
|
||||
username: string;
|
||||
token: string;
|
||||
authType: "pat" | "app";
|
||||
appId?: string;
|
||||
installationId?: string;
|
||||
enabled: boolean;
|
||||
repositoryCount: number;
|
||||
locked: boolean;
|
||||
@@ -39,13 +43,28 @@ export async function toSourceApiItem(
|
||||
repositoryCount: number
|
||||
): Promise<SourceApiItem> {
|
||||
const { decryptSourceToken } = await import("@/lib/sources");
|
||||
const token = decryptSourceToken(source.token);
|
||||
// A stored App credential holds the private key: never return it, whatever
|
||||
// the provider, and don't fail the whole listing over a damaged one.
|
||||
const appCredential = isGitHubAppCredential(token);
|
||||
let app: ReturnType<typeof parseGitHubAppCredentials> = null;
|
||||
if (appCredential && source.provider === "github") {
|
||||
try {
|
||||
app = parseGitHubAppCredentials(token);
|
||||
} catch {
|
||||
app = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
provider: source.provider,
|
||||
url: source.url ?? "",
|
||||
username: source.username,
|
||||
token: decryptSourceToken(source.token),
|
||||
token: appCredential ? "" : token,
|
||||
authType: app ? "app" : "pat",
|
||||
appId: app?.appId,
|
||||
installationId: app?.installationId,
|
||||
enabled: source.enabled,
|
||||
repositoryCount,
|
||||
locked: repositoryCount > 0,
|
||||
|
||||
@@ -17,6 +17,11 @@ import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { sources } from "@/lib/db/schema";
|
||||
import { decrypt, encrypt } from "@/lib/utils/encryption";
|
||||
import {
|
||||
isGitHubAppCredential,
|
||||
parseGitHubAppCredentials,
|
||||
resolveGitHubAccessToken,
|
||||
} from "@/lib/github-app-credentials";
|
||||
import {
|
||||
SOURCE_PROVIDER_KINDS,
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
@@ -466,6 +471,40 @@ export function decryptSourceToken(token: string | null | undefined): string {
|
||||
return token ? decrypt(token) : "";
|
||||
}
|
||||
|
||||
/** True when a source authenticates as a GitHub App installation. */
|
||||
export function isGitHubAppSource(source: Pick<SourceRecord, "provider" | "token">): boolean {
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (!isGitHubAppCredential(token)) return false;
|
||||
// An App credential on a non-GitHub row (a provider switch that kept the
|
||||
// stored value) must never be sent anywhere as a token.
|
||||
if (source.provider !== "github") {
|
||||
throw new Error("This source holds GitHub App credentials but is not a GitHub source. Enter a token for it.");
|
||||
}
|
||||
return parseGitHubAppCredentials(token) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The token to authenticate to a source with: the stored PAT, or a fresh
|
||||
* installation token for a GitHub App source.
|
||||
*/
|
||||
export async function sourceAccessToken(
|
||||
source: SourceRecord,
|
||||
options: { minValidityMs?: number } = {}
|
||||
): Promise<string> {
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (source.provider !== "github") {
|
||||
if (isGitHubAppCredential(token)) {
|
||||
throw new Error("This source holds GitHub App credentials but is not a GitHub source. Enter a token for it.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
return resolveGitHubAccessToken(
|
||||
token,
|
||||
resolveGitHubApiBaseUrl(source.url) || process.env.GH_API_URL || process.env.GITHUB_API_URL || "https://api.github.com",
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The REST API base for a GitHub-kind source. github.com (or empty) returns
|
||||
* undefined so the GH_API_URL environment default applies (GHES/GHEC setups
|
||||
|
||||
@@ -442,7 +442,8 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
// Decrypt tokens
|
||||
if (githubConfig.token) {
|
||||
githubConfig.token = decrypt(githubConfig.token);
|
||||
const token = decrypt(githubConfig.token);
|
||||
githubConfig.token = token.startsWith("github-app:v1:") ? "" : token;
|
||||
}
|
||||
|
||||
if (giteaConfig.token) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* GET /api/dashboard: lastSync counts manual syncs, not only scheduler runs.
|
||||
*
|
||||
* The route imports @/lib/db and @/lib/auth-guards, which are replaced here
|
||||
* through mock.module; bun's mock.module is process-wide and leaks into other
|
||||
* test files, so this file registers nothing in the shared process and
|
||||
* re-runs itself in an isolated child (same harness as
|
||||
* ../job/sync-org.test.ts).
|
||||
*/
|
||||
import { expect, mock, test } from "bun:test";
|
||||
import { repositories, organizations, mirrorJobs, configs } from "@/lib/db/schema";
|
||||
|
||||
const CHILD_FLAG = "GM_DASHBOARD_ROUTE_ISOLATED";
|
||||
const isChild = !!process.env[CHILD_FLAG];
|
||||
|
||||
if (!isChild) {
|
||||
test("dashboard route - isolated child suite", () => {
|
||||
const res = Bun.spawnSync({
|
||||
cmd: [process.execPath, "test", import.meta.path],
|
||||
env: { ...process.env, [CHILD_FLAG]: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
if (res.exitCode !== 0) {
|
||||
console.error(res.stdout.toString());
|
||||
console.error(res.stderr.toString());
|
||||
}
|
||||
expect(res.exitCode).toBe(0);
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
if (isChild) {
|
||||
const mirroredAt = new Date("2026-09-28T12:00:00Z");
|
||||
const repo = { id: "repo-1", userId: "user-1", name: "example", status: "synced", lastMirrored: mirroredAt };
|
||||
const activity = { id: "job-1", userId: "user-1", status: "synced" };
|
||||
const results = [
|
||||
[repo], [], [activity], [{ scheduleConfig: { lastRun: null } }],
|
||||
[{ value: 1 }], [{ value: 0 }], [{ value: 1 }], [{ lastMirrored: mirroredAt }],
|
||||
];
|
||||
let queryIndex = 0;
|
||||
|
||||
mock.module("@/lib/db", () => ({
|
||||
db: {
|
||||
select: () => {
|
||||
const rows = results[queryIndex++];
|
||||
const query = {
|
||||
from: () => query,
|
||||
where: () => query,
|
||||
orderBy: () => query,
|
||||
limit: async () => rows,
|
||||
then: (resolve: (value: unknown) => void) => resolve(rows),
|
||||
};
|
||||
return query;
|
||||
},
|
||||
},
|
||||
repositories, organizations, mirrorJobs, configs,
|
||||
}));
|
||||
|
||||
mock.module("@/lib/auth-guards", () => ({
|
||||
requireAuthenticatedUserId: async () => ({ userId: "user-1" }),
|
||||
}));
|
||||
|
||||
const { GET } = await import("./index");
|
||||
|
||||
test("Dashboard includes manual syncs even when the scheduler has never run", async () => {
|
||||
queryIndex = 0;
|
||||
const response = await GET({ request: new Request("http://localhost/api/dashboard"), locals: {} } as any);
|
||||
const body = await response.json();
|
||||
expect(body).toMatchObject({
|
||||
success: true,
|
||||
repoCount: 1,
|
||||
orgCount: 0,
|
||||
mirroredCount: 1,
|
||||
repositories: [{ ...repo, lastMirrored: mirroredAt.toISOString() }],
|
||||
activities: [activity],
|
||||
lastSync: mirroredAt.toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, repositories, organizations, mirrorJobs, configs } from "@/lib/db";
|
||||
import { eq, count, and, sql, or } from "drizzle-orm";
|
||||
import { eq, count, and, sql, or, isNotNull } from "drizzle-orm";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import type { DashboardApiResponse } from "@/types/dashboard";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
@@ -19,6 +19,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
[{ value: repoCount }],
|
||||
[{ value: orgCount }],
|
||||
[{ value: mirroredCount }],
|
||||
[latestMirrored],
|
||||
] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@@ -66,8 +67,19 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({ lastMirrored: repositories.lastMirrored })
|
||||
.from(repositories)
|
||||
.where(and(eq(repositories.userId, userId), isNotNull(repositories.lastMirrored)))
|
||||
.orderBy(sql`${repositories.lastMirrored} DESC`)
|
||||
.limit(1),
|
||||
]);
|
||||
|
||||
const scheduledRun = userConfig?.scheduleConfig.lastRun;
|
||||
const lastSync = latestMirrored?.lastMirrored && scheduledRun
|
||||
? new Date(Math.max(+latestMirrored.lastMirrored, +new Date(scheduledRun)))
|
||||
: latestMirrored?.lastMirrored ?? scheduledRun ?? null;
|
||||
|
||||
const successResponse: DashboardApiResponse = {
|
||||
success: true,
|
||||
message: "Dashboard data loaded successfully",
|
||||
@@ -77,7 +89,7 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
repositories: userRepos,
|
||||
organizations: userOrgs,
|
||||
activities: userLogs,
|
||||
lastSync: userConfig?.scheduleConfig.lastRun ?? null,
|
||||
lastSync,
|
||||
};
|
||||
|
||||
return jsonResponse({ data: successResponse });
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getGithubStarredListNames,
|
||||
} from "@/lib/github";
|
||||
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||
import { parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
|
||||
@@ -45,6 +46,9 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
}
|
||||
|
||||
const token = getDecryptedGitHubToken(config);
|
||||
if (parseGitHubAppCredentials(token)) {
|
||||
return jsonResponse({ data: { success: true, lists: [] }, status: 200 });
|
||||
}
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(token, userId, githubUsername);
|
||||
const lists = await getGithubStarredListNames({ octokit });
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { APIRoute } from "astro";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
import { assertSafeOutboundUrl, OutboundUrlError } from "@/lib/utils/outbound-url";
|
||||
import { encodeGitHubAppCredentials, parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
import { decryptSourceToken, listSources } from "@/lib/sources";
|
||||
import {
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
createSourceProvider,
|
||||
@@ -34,7 +36,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const label = SOURCE_PROVIDER_LABELS[provider];
|
||||
|
||||
try {
|
||||
if (!token) {
|
||||
let credential = token;
|
||||
if (body?.authType === "app") {
|
||||
if (provider !== "github") return json({ success: false, message: "GitHub App requires a GitHub source" }, 400);
|
||||
const source = typeof body.sourceId === "string"
|
||||
? (await listSources(authResult.userId)).find((item) => item.id === body.sourceId && item.provider === "github")
|
||||
: null;
|
||||
const previous = source ? parseGitHubAppCredentials(decryptSourceToken(source.token)) : null;
|
||||
try {
|
||||
credential = encodeGitHubAppCredentials({
|
||||
appId: String(body.appId ?? ""),
|
||||
installationId: String(body.installationId ?? ""),
|
||||
privateKey: typeof body.privateKey === "string" && body.privateKey.trim() ? body.privateKey : previous?.privateKey ?? "",
|
||||
});
|
||||
} catch (error) {
|
||||
return json({ success: false, message: (error as Error).message }, 400);
|
||||
}
|
||||
}
|
||||
if (!credential) {
|
||||
return json({ success: false, message: `${label} token is required` }, 400);
|
||||
}
|
||||
|
||||
@@ -53,7 +72,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
provider,
|
||||
url: instanceUrl,
|
||||
username: typeof username === "string" ? username : "",
|
||||
token,
|
||||
token: credential,
|
||||
});
|
||||
|
||||
const account = await sourceProvider.testConnection();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import type { Repository } from "@/lib/db/schema";
|
||||
import type { SourceRecord } from "@/lib/sources";
|
||||
import {
|
||||
@@ -101,6 +103,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const source = findSourceForRepository(repository, sources);
|
||||
if (!source || source.provider !== "github") return null;
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (isGitHubAppCredential(token)) {
|
||||
return createGitHubAppClient(token, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) });
|
||||
}
|
||||
// A tokenless GitHub source still gets an anonymous public client
|
||||
// (60 req/hr) so public-repo metadata mirrors; an empty token must
|
||||
// never reach createGitHubClient, which would send `auth: ""`.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import type { SourceRecord } from "@/lib/sources";
|
||||
import {
|
||||
decryptSourceToken,
|
||||
@@ -93,6 +95,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const octokitForSource = (source: SourceRecord | null): Octokit | null => {
|
||||
if (!source || source.provider !== "github") return null;
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (isGitHubAppCredential(token)) {
|
||||
return createGitHubAppClient(token, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) });
|
||||
}
|
||||
// A tokenless GitHub source still gets an anonymous public client
|
||||
// (60 req/hr) so public-repo metadata mirrors; an empty token must
|
||||
// never reach createGitHubClient, which would send `auth: ""`.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import type { SourceRecord } from "@/lib/sources";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import {
|
||||
decryptSourceToken,
|
||||
findSourceForRepository,
|
||||
@@ -94,6 +96,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const octokitForSource = (source: SourceRecord | null) => {
|
||||
if (!source || source.provider !== "github") return null;
|
||||
const token = decryptSourceToken(source.token);
|
||||
if (isGitHubAppCredential(token)) {
|
||||
return createGitHubAppClient(token, { userId, apiBaseUrl: resolveGitHubApiBaseUrl(source.url) });
|
||||
}
|
||||
// A tokenless GitHub source still gets an anonymous public client
|
||||
// (60 req/hr) so public-repo metadata mirrors; an empty token must
|
||||
// never reach createGitHubClient, which would send `auth: ""`.
|
||||
|
||||
@@ -6,6 +6,9 @@ import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||
import { RateLimitManager } from "@/lib/rate-limit-manager";
|
||||
import { createGitHubClient } from "@/lib/github";
|
||||
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||
import { createGitHubAppClient } from "@/lib/github-app-client";
|
||||
import { isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import { resolveGitHubApiBaseUrl } from "@/lib/sources";
|
||||
import { configs } from "@/lib/db";
|
||||
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
|
||||
@@ -32,7 +35,12 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
||||
if (config && config.githubConfig?.token && resolveSourceProviderKind(config) === "github") {
|
||||
const decryptedToken = getDecryptedGitHubToken(config);
|
||||
const githubUsername = config.githubConfig?.owner || undefined;
|
||||
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
const octokit = isGitHubAppCredential(decryptedToken)
|
||||
? createGitHubAppClient(decryptedToken, {
|
||||
userId,
|
||||
apiBaseUrl: resolveGitHubApiBaseUrl(config.githubConfig?.url),
|
||||
})
|
||||
: createGitHubClient(decryptedToken, userId, githubUsername);
|
||||
|
||||
// This will update the rate limit in the database
|
||||
await RateLimitManager.checkGitHubRateLimit(octokit, userId);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { encodeGitHubAppCredentials, parseGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
|
||||
let sourceRows: any[] = [];
|
||||
let repositoryCount = 0;
|
||||
@@ -138,6 +139,66 @@ beforeEach(() => {
|
||||
repositoryCount = 2;
|
||||
});
|
||||
|
||||
test("editing App IDs preserves the private key without returning it", async () => {
|
||||
const privateKey = "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----";
|
||||
sourceRows[0].token = encrypted(encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey }));
|
||||
const response = await put("source-1", { authType: "app", appId: "123", installationId: "789", privateKey: "" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(parseGitHubAppCredentials(sourceRows[0].token.slice(4))).toEqual({ appId: "123", installationId: "789", privateKey });
|
||||
const body = await response.json();
|
||||
expect(body.source).toMatchObject({ authType: "app", installationId: "789", token: "" });
|
||||
expect(JSON.stringify(body)).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
describe("PUT /api/sources/:id with stored GitHub App credentials", () => {
|
||||
const privateKey = "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----";
|
||||
let appToken: string;
|
||||
|
||||
beforeEach(() => {
|
||||
appToken = encrypted(encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey }));
|
||||
sourceRows[0].token = appToken;
|
||||
});
|
||||
|
||||
test("refuses a provider switch that would keep the App credentials", async () => {
|
||||
const response = await put("source-1", {
|
||||
provider: "gitlab",
|
||||
url: "https://gitlab.example.com",
|
||||
confirmSourceChange: true,
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const text = await response.text();
|
||||
expect(text).toContain("GitLab token");
|
||||
expect(text).not.toContain("PRIVATE KEY");
|
||||
expect(sourceRows[0]).toMatchObject({ provider: "github", token: appToken });
|
||||
});
|
||||
|
||||
test("refuses switching to token auth without a token", async () => {
|
||||
const response = await put("source-1", { authType: "pat" });
|
||||
expect(response.status).toBe(400);
|
||||
expect(sourceRows[0].token).toBe(appToken);
|
||||
});
|
||||
|
||||
test("a provider switch with a new token replaces the App credentials", async () => {
|
||||
const response = await put("source-1", {
|
||||
provider: "gitlab",
|
||||
url: "https://gitlab.example.com",
|
||||
token: "glpat-new",
|
||||
confirmSourceChange: true,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(sourceRows[0]).toMatchObject({ provider: "gitlab", token: encrypted("glpat-new") });
|
||||
});
|
||||
|
||||
test("refuses App credentials sent through the token field", async () => {
|
||||
sourceRows[0].token = encrypted("ghp_old");
|
||||
const response = await put("source-1", {
|
||||
token: encodeGitHubAppCredentials({ appId: "1", installationId: "2", privateKey }),
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(sourceRows[0].token).toBe(encrypted("ghp_old"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/sources/:id", () => {
|
||||
test("answers 404 for a source that is not the account's", async () => {
|
||||
const response = await put("source-404", { name: "Renamed" });
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
updateSource,
|
||||
} from "@/lib/sources";
|
||||
import { toSourceApiItem } from "@/lib/sources-api";
|
||||
import {
|
||||
encodeGitHubAppCredentials,
|
||||
isGitHubAppCredential,
|
||||
parseGitHubAppCredentials,
|
||||
} from "@/lib/github-app-credentials";
|
||||
import { decryptSourceToken } from "@/lib/sources";
|
||||
|
||||
/** Repositories imported from this source; they are what locks it. */
|
||||
async function countRepositoriesForSource(
|
||||
@@ -59,6 +65,10 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
url?: unknown;
|
||||
username?: unknown;
|
||||
token?: unknown;
|
||||
authType?: unknown;
|
||||
appId?: unknown;
|
||||
installationId?: unknown;
|
||||
privateKey?: unknown;
|
||||
enabled?: unknown;
|
||||
confirmSourceChange?: unknown;
|
||||
};
|
||||
@@ -75,10 +85,40 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
const provider: SourceProviderKind = isSourceProviderKind(body.provider)
|
||||
? body.provider
|
||||
: existing.provider;
|
||||
|
||||
// GitLab and Gitea sources point at an instance; GitHub needs no URL.
|
||||
if (body.authType !== undefined && body.authType !== "app" && body.authType !== "pat") return jsonError("Unknown authentication method.", 400);
|
||||
if (body.authType === "app" && provider !== "github") return jsonError("GitHub App authentication requires a GitHub source.", 400);
|
||||
let token = typeof body.token === "string" ? body.token : undefined;
|
||||
if (body.authType === "app") {
|
||||
const prior = existing.provider === "github" ? parseGitHubAppCredentials(decryptSourceToken(existing.token)) : null;
|
||||
try {
|
||||
token = encodeGitHubAppCredentials({
|
||||
appId: String(body.appId ?? ""),
|
||||
installationId: String(body.installationId ?? ""),
|
||||
privateKey: typeof body.privateKey === "string" && body.privateKey.trim() ? body.privateKey : prior?.privateKey ?? "",
|
||||
});
|
||||
} catch (error) {
|
||||
return jsonError((error as Error).message, 400);
|
||||
}
|
||||
} else if (isGitHubAppCredential(token)) {
|
||||
return jsonError("Use the GitHub App fields to enter App credentials.", 400);
|
||||
}
|
||||
// The stored App credential holds the private key. Moving the source to
|
||||
// another provider or to token auth without a new token would keep it,
|
||||
// return it from the API and send it to the new host as the token.
|
||||
if (
|
||||
body.authType !== "app" &&
|
||||
(provider !== "github" || body.authType === "pat") &&
|
||||
!token?.trim() &&
|
||||
isGitHubAppCredential(decryptSourceToken(existing.token))
|
||||
) {
|
||||
return jsonError(
|
||||
`Enter a ${SOURCE_PROVIDER_LABELS[provider]} token to stop using the GitHub App credentials on this source.`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// All sources may point at their own instance; validate explicit URLs.
|
||||
if (
|
||||
provider !== "github" &&
|
||||
typeof body.url === "string" &&
|
||||
body.url.trim() &&
|
||||
!isValidSourceUrl(body.url)
|
||||
@@ -88,7 +128,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
400
|
||||
);
|
||||
}
|
||||
if (provider !== "github" && typeof body.url === "string" && body.url.trim()) {
|
||||
if (typeof body.url === "string" && body.url.trim()) {
|
||||
const blocked = await blockedOutboundUrlReason(body.url);
|
||||
if (blocked) return jsonError(blocked, 400);
|
||||
}
|
||||
@@ -118,7 +158,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
url: typeof body.url === "string" ? body.url : undefined,
|
||||
username: typeof body.username === "string" ? body.username : undefined,
|
||||
// An empty token keeps the stored one (the service preserves it).
|
||||
token: typeof body.token === "string" ? body.token : undefined,
|
||||
token,
|
||||
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
import { encodeGitHubAppCredentials } from "@/lib/github-app-credentials";
|
||||
|
||||
let sourceRows: any[] = [];
|
||||
let countRows: Array<{ sourceId: string | null; count: number }> = [];
|
||||
@@ -135,6 +136,22 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /api/sources", () => {
|
||||
test("does not return a GitHub App private key", async () => {
|
||||
sourceRows = [{ id: "app-1", userId: "user-1", name: "App", provider: "github", url: "https://github.com", username: "acme", token: encrypted(encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----" })), enabled: true }];
|
||||
const response = await get();
|
||||
const body = await response.json();
|
||||
expect(body.sources[0]).toMatchObject({ authType: "app", appId: "123", installationId: "456", token: "" });
|
||||
expect(JSON.stringify(body)).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
test("never returns App credentials stored on a non-GitHub source", async () => {
|
||||
sourceRows = [{ id: "gl-1", userId: "user-1", name: "GitLab", provider: "gitlab", url: "https://gitlab.example.com", username: "acme", token: encrypted(encodeGitHubAppCredentials({ appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----" })), enabled: true }];
|
||||
const response = await get();
|
||||
const body = await response.json();
|
||||
expect(body.sources[0]).toMatchObject({ provider: "gitlab", authType: "pat", token: "" });
|
||||
expect(JSON.stringify(body)).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
test("lists sources with repository counts, lock state and decrypted tokens", async () => {
|
||||
sourceRows = [
|
||||
{
|
||||
@@ -190,6 +207,15 @@ describe("GET /api/sources", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/sources", () => {
|
||||
test("creates an App source with encrypted credential data and no private key in the response", async () => {
|
||||
const response = await post({ provider: "github", username: "acme", authType: "app", appId: "123", installationId: "456", privateKey: "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----" });
|
||||
expect(response.status).toBe(201);
|
||||
expect(sourceRows[0].token).toContain("enc:github-app:v1:");
|
||||
const body = await response.json();
|
||||
expect(body.source).toMatchObject({ authType: "app", appId: "123", installationId: "456", token: "" });
|
||||
expect(JSON.stringify(body)).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
test("rejects an unknown source provider", async () => {
|
||||
const response = await post({ provider: "bitbucket", username: "me", token: "t" });
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
||||
import { blockedOutboundUrlReason } from "@/lib/utils/outbound-url";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { createSource } from "@/lib/sources";
|
||||
import { encodeGitHubAppCredentials, isGitHubAppCredential } from "@/lib/github-app-credentials";
|
||||
import {
|
||||
isDuplicateSourceError,
|
||||
loadSourceApiItems,
|
||||
@@ -43,6 +44,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
url?: unknown;
|
||||
username?: unknown;
|
||||
token?: unknown;
|
||||
authType?: unknown;
|
||||
appId?: unknown;
|
||||
installationId?: unknown;
|
||||
privateKey?: unknown;
|
||||
enabled?: unknown;
|
||||
};
|
||||
|
||||
@@ -56,9 +61,26 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
);
|
||||
}
|
||||
const provider = body.provider;
|
||||
if (body.authType !== undefined && body.authType !== "app" && body.authType !== "pat") {
|
||||
return new Response(JSON.stringify({ success: false, message: "Unknown authentication method." }), { status: 400 });
|
||||
}
|
||||
if (body.authType === "app" && provider !== "github") {
|
||||
return new Response(JSON.stringify({ success: false, message: "GitHub App authentication requires a GitHub source." }), { status: 400 });
|
||||
}
|
||||
let token: string | undefined;
|
||||
try {
|
||||
token = body.authType === "app"
|
||||
? encodeGitHubAppCredentials({ appId: String(body.appId ?? ""), installationId: String(body.installationId ?? ""), privateKey: String(body.privateKey ?? "") })
|
||||
: typeof body.token === "string" ? body.token : undefined;
|
||||
if (body.authType !== "app" && isGitHubAppCredential(token)) {
|
||||
throw new Error("Use the GitHub App fields to enter App credentials.");
|
||||
}
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ success: false, message: (error as Error).message }), { status: 400 });
|
||||
}
|
||||
|
||||
// GitLab and Gitea sources point at an instance; GitHub needs no URL.
|
||||
if (provider !== "github") {
|
||||
// All sources may point at their own instance; validate explicit URLs.
|
||||
{
|
||||
const rawSourceUrl = typeof body.url === "string" ? body.url.trim() : "";
|
||||
if (rawSourceUrl && !isValidSourceUrl(rawSourceUrl)) {
|
||||
return new Response(
|
||||
@@ -88,7 +110,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
provider,
|
||||
url: typeof body.url === "string" ? body.url : undefined,
|
||||
username: typeof body.username === "string" ? body.username : undefined,
|
||||
token: typeof body.token === "string" ? body.token : undefined,
|
||||
token,
|
||||
enabled: typeof body.enabled === "boolean" ? body.enabled : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -130,6 +130,9 @@ export interface SourceApiRecord {
|
||||
url: string;
|
||||
username: string;
|
||||
token: string;
|
||||
authType?: "pat" | "app";
|
||||
appId?: string;
|
||||
installationId?: string;
|
||||
enabled: boolean;
|
||||
repositoryCount: number;
|
||||
locked: boolean;
|
||||
|
||||
Reference in New Issue
Block a user