Files
vibn-frontend/lib/integrations/github.ts
Mark Henderson 90bed6ab31 feat(github): OAuth integration + repo picker for Import flow
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
2026-04-29 16:44:13 -07:00

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],
);
}