User can now click "Connect GitHub" inside the Import-existing-code
flow, sign in via GitHub, and pick a repo from a searchable list of
their own + collaborator + org repos. Both public and private repos
work — the encrypted access token on the user's account is auto-
attached when the create endpoint runs the agent-runner mirror.
OAuth flow:
- GET /api/integrations/github/connect — generates state, sets
a 10-min httpOnly cookie, 302s to GitHub authorize.
- GET /api/integrations/github/callback — verifies state,
exchanges code for token, fetches /user, encrypts the
token with secret-box (AES-256-GCM, VIBN_SECRETS_KEY) and
persists it on fs_users.data.integrations.github.
Bounces back to ?gh_connected=login or ?gh_error=msg.
- GET /api/integrations/github/repos — server-side fetches
the connected user's repos (per_page=100, sort=pushed,
affiliation=owner+collaborator+org_member). Returns the
GitHub login + a stripped repo summary; never the token.
- POST /api/integrations/github/disconnect — drops the integration
from fs_users (does NOT revoke on github.com).
Scopes requested: repo, read:user.
Token storage:
- Encrypted at rest with secret-box (lib/auth/secret-box.ts) using
VIBN_SECRETS_KEY. Tokens never leave the server.
- One token per fs_users row, keyed by email.
ImportSetup UI:
- On mount, fires /repos to detect connection state.
- If connected: shows a connected-as-@login chip with disconnect
link, a search-as-you-type repo picker (max 220px scroll, badges
for Private / language), and a "paste a different URL instead"
escape hatch.
- If not connected: shows a Connect GitHub card with a public-URL
fallback inline.
- On return from OAuth (?gh_connected=… or ?gh_error=…), surfaces
a toast and silently refreshes the repo list.
- Selected repo carries default_branch + repo id into the create
payload so we can store them on the project for later UI hints.
/api/projects/create:
- When a githubRepoUrl is mirrored, falls back to the user's
OAuth-linked token if no PAT is explicitly passed. Means the
flow "just works" for private repos once GitHub is connected.
Required env (already set in production):
- GITHUB_CLIENT_ID
- GITHUB_CLIENT_SECRET
Made-with: Cursor
190 lines
6.2 KiB
TypeScript
190 lines
6.2 KiB
TypeScript
/**
|
|
* GitHub OAuth integration helpers.
|
|
*
|
|
* Storage layout — fs_users.data.integrations.github:
|
|
* {
|
|
* login: "octocat",
|
|
* accessToken: <encryptSecret(token)>, // encrypted at rest
|
|
* scope: "repo,read:user",
|
|
* connectedAt: ISO,
|
|
* }
|
|
*
|
|
* Tokens never leave the server. All API calls happen server-side; the
|
|
* UI only ever sees the GitHub `login` and the list of repos.
|
|
*
|
|
* Scopes requested: `repo` (private+public read+write so we can mirror)
|
|
* `read:user` (so we can show the connected username)
|
|
*/
|
|
|
|
import { encryptSecret, decryptSecret } from "@/lib/auth/secret-box";
|
|
import { query } from "@/lib/db-postgres";
|
|
|
|
const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? "";
|
|
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? "";
|
|
const SCOPES = "repo,read:user";
|
|
|
|
export function isGithubOauthConfigured(): boolean {
|
|
return CLIENT_ID.length > 0 && CLIENT_SECRET.length > 0;
|
|
}
|
|
|
|
/** Build the GitHub authorize URL. `state` MUST be unguessable per request. */
|
|
export function buildAuthorizeUrl(state: string, callbackUrl: string): string {
|
|
const u = new URL("https://github.com/login/oauth/authorize");
|
|
u.searchParams.set("client_id", CLIENT_ID);
|
|
u.searchParams.set("redirect_uri", callbackUrl);
|
|
u.searchParams.set("scope", SCOPES);
|
|
u.searchParams.set("state", state);
|
|
u.searchParams.set("allow_signup", "true");
|
|
return u.toString();
|
|
}
|
|
|
|
interface TokenExchangeResult {
|
|
accessToken: string;
|
|
scope: string;
|
|
tokenType: string;
|
|
}
|
|
|
|
/** POST /login/oauth/access_token — exchange auth code for an access token. */
|
|
export async function exchangeCodeForToken(
|
|
code: string, callbackUrl: string,
|
|
): Promise<TokenExchangeResult> {
|
|
const res = await fetch("https://github.com/login/oauth/access_token", {
|
|
method: "POST",
|
|
headers: {
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
client_id: CLIENT_ID,
|
|
client_secret: CLIENT_SECRET,
|
|
code,
|
|
redirect_uri: callbackUrl,
|
|
}),
|
|
});
|
|
const body = await res.json() as {
|
|
access_token?: string; scope?: string; token_type?: string;
|
|
error?: string; error_description?: string;
|
|
};
|
|
if (!res.ok || !body.access_token) {
|
|
throw new Error(body.error_description || body.error || "GitHub token exchange failed");
|
|
}
|
|
return {
|
|
accessToken: body.access_token,
|
|
scope: body.scope ?? "",
|
|
tokenType: body.token_type ?? "bearer",
|
|
};
|
|
}
|
|
|
|
/** GET /user — fetch the authenticated user's login + name. */
|
|
export async function getAuthenticatedUser(token: string) {
|
|
const res = await fetch("https://api.github.com/user", {
|
|
headers: {
|
|
"Authorization": `Bearer ${token}`,
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`GitHub /user failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
return res.json() as Promise<{ login: string; name: string | null; avatar_url: string }>;
|
|
}
|
|
|
|
interface GithubRepo {
|
|
id: number;
|
|
name: string;
|
|
full_name: string;
|
|
private: boolean;
|
|
description: string | null;
|
|
default_branch: string;
|
|
html_url: string;
|
|
pushed_at: string | null;
|
|
language: string | null;
|
|
fork: boolean;
|
|
}
|
|
|
|
/**
|
|
* GET /user/repos — list repos the authenticated user can access.
|
|
* Returns at most `perPage` repos; we cap at 100 (GitHub max) and don't
|
|
* paginate further. The picker UI does client-side filter on top.
|
|
*/
|
|
export async function listUserRepos(token: string, perPage = 100): Promise<GithubRepo[]> {
|
|
const u = new URL("https://api.github.com/user/repos");
|
|
u.searchParams.set("per_page", String(perPage));
|
|
u.searchParams.set("sort", "pushed");
|
|
u.searchParams.set("affiliation", "owner,collaborator,organization_member");
|
|
const res = await fetch(u.toString(), {
|
|
headers: {
|
|
"Authorization": `Bearer ${token}`,
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`GitHub /user/repos failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
return res.json() as Promise<GithubRepo[]>;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// fs_users persistence
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export interface StoredGithubIntegration {
|
|
login: string;
|
|
accessToken: string; // encrypted
|
|
scope: string;
|
|
connectedAt: string;
|
|
}
|
|
|
|
/** Write the integration to fs_users.data.integrations.github. */
|
|
export async function persistGithubIntegration(
|
|
email: string, login: string, plainToken: string, scope: string,
|
|
): Promise<void> {
|
|
const blob: StoredGithubIntegration = {
|
|
login,
|
|
accessToken: encryptSecret(plainToken),
|
|
scope,
|
|
connectedAt: new Date().toISOString(),
|
|
};
|
|
await query(
|
|
`UPDATE fs_users
|
|
SET data = jsonb_set(
|
|
jsonb_set(data, '{integrations}', COALESCE(data->'integrations','{}'::jsonb), true),
|
|
'{integrations,github}', $2::jsonb, true
|
|
),
|
|
updated_at = NOW()
|
|
WHERE data->>'email' = $1`,
|
|
[email, JSON.stringify(blob)],
|
|
);
|
|
}
|
|
|
|
/** Read + decrypt the GitHub access token for a user, if any. */
|
|
export async function loadGithubIntegration(
|
|
email: string,
|
|
): Promise<{ login: string; token: string; scope: string } | null> {
|
|
const rows = await query<{ blob: StoredGithubIntegration | null }>(
|
|
`SELECT data->'integrations'->'github' AS blob
|
|
FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
|
[email],
|
|
);
|
|
const blob = rows[0]?.blob;
|
|
if (!blob || !blob.accessToken) return null;
|
|
try {
|
|
return { login: blob.login, token: decryptSecret(blob.accessToken), scope: blob.scope };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Drop the stored integration. */
|
|
export async function disconnectGithubIntegration(email: string): Promise<void> {
|
|
await query(
|
|
`UPDATE fs_users
|
|
SET data = data #- '{integrations,github}',
|
|
updated_at = NOW()
|
|
WHERE data->>'email' = $1`,
|
|
[email],
|
|
);
|
|
}
|