"use node"; import { env } from "@code/env/convex"; import { decodeGitConnectionInput } from "@code/primitives/execution-runtime"; import { GITHUB_SERVER_URL, PUTER_GIT_SERVER_URL, } from "@code/primitives/git-provider"; import { makeFunctionReference } from "convex/server"; import { ConvexError, v } from "convex/values"; import { Effect } from "effect"; import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import { action } from "./_generated/server"; import type { ActionCtx } from "./_generated/server"; import { authComponent, createAuth } from "./auth"; const MIN_SEARCH_QUERY_LENGTH = 2; const DEFAULT_SEARCH_PER_PAGE = 20; const MAX_SEARCH_PER_PAGE = 50; const encryptionKey = async (): Promise => { if (!env.GIT_CREDENTIAL_ENCRYPTION_KEY) { throw new ConvexError("Git credential encryption is not configured"); } const bytes = Buffer.from(env.GIT_CREDENTIAL_ENCRYPTION_KEY, "base64url"); if (bytes.byteLength !== 32) { throw new ConvexError("Git credential encryption key must be 32 bytes"); } return await crypto.subtle.importKey("raw", bytes, "AES-GCM", false, [ "encrypt", "decrypt", ]); }; const encryptCredential = async (credential: string) => { const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { iv, name: "AES-GCM" }, await encryptionKey(), new TextEncoder().encode(credential) ); return { credentialCiphertext: Buffer.from(encrypted).toString("base64url"), credentialIv: Buffer.from(iv).toString("base64url"), }; }; export const decryptCredential = async ( credentialCiphertext: string, credentialIv: string ): Promise => { const decrypted = await crypto.subtle.decrypt( { iv: Buffer.from(credentialIv, "base64url"), name: "AES-GCM", }, await encryptionKey(), Buffer.from(credentialCiphertext, "base64url") ); return new TextDecoder().decode(decrypted); }; interface ExternalUserInfo { readonly externalAccountId: string; readonly externalEmail?: string; readonly externalUsername: string; } /** Fetch the Gitea user identity from /api/v1/user using a PAT. */ const fetchGiteaUser = async ( serverUrl: string, token: string ): Promise => { const response = await fetch( `${serverUrl.replace(/\/+$/u, "")}/api/v1/user`, { headers: { authorization: `token ${token}` }, } ); if (!response.ok) { throw new ConvexError( `Gitea user verification failed (${response.status})` ); } const user = (await response.json()) as { readonly email?: string | null; readonly id: number; readonly login: string; }; return { externalAccountId: String(user.id), externalEmail: user.email ?? undefined, externalUsername: user.login, }; }; /** Fetch the GitHub user identity from /user using an OAuth token. */ const fetchGithubUser = async (token: string): Promise => { const response = await fetch("https://api.github.com/user", { headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, }, }); if (!response.ok) { throw new ConvexError( `GitHub user verification failed (${response.status})` ); } const user = (await response.json()) as { readonly email?: string | null; readonly id: number; readonly login: string; }; return { externalAccountId: String(user.id), externalEmail: user.email ?? undefined, externalUsername: user.login, }; }; const syncRepositoriesRef = makeFunctionReference< "action", { connectionId: Id<"gitConnections"> }, { synced: number } >("gitConnections:syncRepositories"); export const connectGitea = action({ args: { token: v.string(), username: v.optional(v.string()), }, handler: async ( ctx, args ): Promise<{ connectionId: Id<"gitConnections"> }> => { const userId = await ctx.auth.getUserIdentity().then((identity) => { if (!identity) { throw new ConvexError("Authentication required"); } return identity.tokenIdentifier; }); const connection = await Effect.runPromise( decodeGitConnectionInput({ credential: args.token, credentialKind: "token", provider: "gitea", serverUrl: PUTER_GIT_SERVER_URL, username: args.username, }) ); // Verify the token and fetch external identity before persisting. const externalUser = await fetchGiteaUser( connection.serverUrl, connection.credential ); const encrypted = await encryptCredential(connection.credential); const connectionId = await ctx.runMutation( internal.gitConnectionData.persist, { ...encrypted, ...externalUser, credentialKind: connection.credentialKind, provider: connection.provider, serverUrl: connection.serverUrl, userId, username: connection.username ?? externalUser.externalUsername, } ); // Sync accessible repositories after connecting. await ctx.runAction(syncRepositoriesRef, { connectionId, }); return { connectionId }; }, }); export const connectGithub = action({ args: {}, handler: async (ctx): Promise<{ connectionId: Id<"gitConnections"> }> => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new ConvexError("Authentication required"); } const { auth, headers } = await authComponent.getAuth(createAuth, ctx); const token = await auth.api.getAccessToken({ body: { providerId: "github" }, headers, }); if (!token.accessToken) { throw new ConvexError("GitHub account is not connected"); } const externalUser = await fetchGithubUser(token.accessToken); const encrypted = await encryptCredential(token.accessToken); const grantedScopesJson = token.scopes && token.scopes.length > 0 ? JSON.stringify(token.scopes) : undefined; const connectionId = await ctx.runMutation( internal.gitConnectionData.persist, { ...encrypted, ...externalUser, credentialKind: "oauth", grantedScopesJson, provider: "github", serverUrl: GITHUB_SERVER_URL, userId: identity.tokenIdentifier, } ); // Sync accessible repositories after connecting. await ctx.runAction(syncRepositoriesRef, { connectionId, }); return { connectionId }; }, }); const getConnectionRef = makeFunctionReference< "query", { connectionId: Id<"gitConnections"> }, Doc<"gitConnections"> | null >("gitConnectionHealth:getConnection"); const resolvePersonalOrgRef = makeFunctionReference< "query", { userId: string }, Id<"organizations"> | null >("gitConnectionHealth:resolvePersonalOrg"); const syncRepositoriesBatchRef = makeFunctionReference< "mutation", { organizationId: Id<"organizations">; providerAccountId: Id<"gitProviderAccounts">; provider: "github" | "gitea"; repos: { cloneUrl: string; defaultBranch: string; fullName: string; name: string; owner: string; private: boolean; providerRepositoryId: string; serverUrl: string; webUrl: string; }[]; serverUrl: string; }, number >("gitProvisioning:syncRepositoriesBatch"); const getActiveGithubConnectionRef = makeFunctionReference< "query", { organizationId: Id<"organizations"> }, Doc<"gitConnections"> | null >("gitConnectionHealth:getActiveGithubConnection"); const getGithubRepoIdsByExternalIdsRef = makeFunctionReference< "query", { organizationId: Id<"organizations">; providerRepositoryIds: string[]; serverUrl: string; }, { id: string; providerRepositoryId: string }[] >("gitConnectionHealth:getGithubRepoIdsByExternalIds"); export const syncRepositories = action({ args: { connectionId: v.id("gitConnections") }, handler: async (ctx, args): Promise<{ synced: number }> => { // Actions have the authenticated identity, but `ctx.runQuery`/`runMutation` // invoked from an action do NOT propagate it — so `requireCurrentOrganization` // inside the sub-functions would see no identity. Resolve the identity here // and pass the organizationId down explicitly. const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new ConvexError("Authentication required"); } const organizationId = await ctx.runQuery(resolvePersonalOrgRef, { userId: identity.tokenIdentifier, }); if (!organizationId) { throw new ConvexError("Organization not found"); } const connection = await ctx.runQuery(getConnectionRef, { connectionId: args.connectionId, }); if (!connection || connection.organizationId !== organizationId) { throw new ConvexError("Git connection not found"); } const token = await decryptCredential( connection.credentialCiphertext, connection.credentialIv ); let repos: { clone_url: string; default_branch: string; full_name: string; html_url: string; id: number; name: string; owner: { login: string }; private: boolean; }[]; if (connection.provider === "gitea") { const response = await fetch( `${connection.serverUrl.replace(/\/+$/u, "")}/api/v1/repos/search?limit=50`, { headers: { authorization: `token ${token}` } } ); if (!response.ok) { throw new ConvexError( `Failed to list Gitea repositories (${response.status})` ); } const body = (await response.json()) as { data: typeof repos }; repos = body.data ?? []; } else { const response = await fetch( "https://api.github.com/user/repos?sort=updated&per_page=50", { headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, }, } ); if (!response.ok) { throw new ConvexError( `Failed to list GitHub repositories (${response.status})` ); } repos = (await response.json()) as typeof repos; } const synced = await ctx.runMutation(syncRepositoriesBatchRef, { organizationId, provider: connection.provider, providerAccountId: connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">), repos: repos.map((repo) => ({ cloneUrl: repo.clone_url, defaultBranch: repo.default_branch ?? "main", fullName: repo.full_name, name: repo.name, owner: repo.owner.login, private: repo.private, providerRepositoryId: String(repo.id), serverUrl: connection.serverUrl, webUrl: repo.html_url, })), serverUrl: connection.serverUrl, }); return { synced }; }, }); // --------------------------------------------------------------------------- // Live GitHub repository search // --------------------------------------------------------------------------- interface GithubSearchRepo { readonly clone_url: string; readonly default_branch: string | null; readonly full_name: string; readonly html_url: string; readonly id: number; readonly name: string; readonly owner: { login: string }; readonly private: boolean; } const resolveActiveGithubConnection = async ( ctx: ActionCtx, args: { connectionId?: Id<"gitConnections"> } ): Promise<{ connection: Doc<"gitConnections">; organizationId: Id<"organizations">; token: string; }> => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new ConvexError("Authentication required"); } const organizationId = await ctx.runQuery(resolvePersonalOrgRef, { userId: identity.tokenIdentifier, }); if (!organizationId) { throw new ConvexError("Organization not found"); } // If no explicit connection given, find the active GitHub connection. const connection = args.connectionId ? await ctx.runQuery(getConnectionRef, { connectionId: args.connectionId }) : await ctx.runQuery(getActiveGithubConnectionRef, { organizationId }); if (!connection || connection.organizationId !== organizationId) { throw new ConvexError("Git connection not found"); } if (connection.provider !== "github") { throw new ConvexError("This action requires a GitHub connection"); } if (connection.state !== undefined && connection.state !== "active") { throw new ConvexError( `GitHub connection is ${connection.state}; reconnect before searching` ); } const token = await decryptCredential( connection.credentialCiphertext, connection.credentialIv ); return { connection, organizationId, token }; }; export interface GithubRepositorySearchResult { readonly cloneUrl: string; readonly defaultBranch: string; readonly fullName: string; readonly gitRepositoryId: string; readonly name: string; readonly owner: string; readonly privacy: "public" | "private"; readonly provider: "github"; readonly webUrl: string; } /** * Live GitHub repository search using the authenticated user's stored * encrypted credential. Searches across the user's accessible repos (owned, * collaborator, and org member) via the GitHub search API, then upserts the * results into gitRepositories so createProjectFromRepository works unchanged. */ export const searchGithubRepositories = action({ args: { connectionId: v.optional(v.id("gitConnections")), perPage: v.optional(v.number()), query: v.string(), }, handler: async ( ctx, args ): Promise => { const query = args.query.trim(); if (query.length < MIN_SEARCH_QUERY_LENGTH) { return []; } const { connection, organizationId, token } = await resolveActiveGithubConnection(ctx, args); // Use /user/repos (not /search/repositories) because it naturally returns // only repos the token has access to — personal, collaborator, AND org // member repos. The search API would return public repos the user doesn't // own, which we don't want to persist. We paginate and filter by name. const perPage = Math.min( Math.max(args.perPage ?? DEFAULT_SEARCH_PER_PAGE, 1), MAX_SEARCH_PER_PAGE ); const searchTerm = query.toLowerCase(); // Fetch up to 3 pages of 100 repos (300 total) and filter by the search // term. This covers users with many repos while staying within rate limits. const matchingRepos: GithubSearchRepo[] = []; const maxPages = 3; for (let page = 1; page <= maxPages; page += 1) { const response = await fetch( `https://api.github.com/user/repos?sort=updated&per_page=100&page=${page}`, { headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, }, } ); if (response.status === 401 || response.status === 403) { throw new ConvexError( "GitHub rejected the stored token; reauthorize the connection" ); } if (!response.ok) { throw new ConvexError( `GitHub repository listing failed (${response.status})` ); } const pageRepos = (await response.json()) as GithubSearchRepo[]; if (pageRepos.length === 0) { break; } const matched = pageRepos.filter( (repo) => repo.name.toLowerCase().includes(searchTerm) || repo.full_name.toLowerCase().includes(searchTerm) ); matchingRepos.push(...matched); if (matchingRepos.length >= perPage || pageRepos.length < 100) { break; } } const repos = matchingRepos.slice(0, perPage); if (repos.length === 0) { return []; } // Persist search results into gitRepositories so the existing // createProjectFromRepository mutation works unchanged. await ctx.runMutation(syncRepositoriesBatchRef, { organizationId, provider: "github", providerAccountId: connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">), repos: repos.map((repo) => ({ cloneUrl: repo.clone_url, defaultBranch: repo.default_branch ?? "main", fullName: repo.full_name, name: repo.name, owner: repo.owner.login, private: repo.private, providerRepositoryId: String(repo.id), serverUrl: GITHUB_SERVER_URL, webUrl: repo.html_url, })), serverUrl: GITHUB_SERVER_URL, }); // Read back the upserted repos so we return stable gitRepositoryIds. const repoIds = await ctx.runQuery(getGithubRepoIdsByExternalIdsRef, { organizationId, providerRepositoryIds: repos.map((repo) => String(repo.id)), serverUrl: GITHUB_SERVER_URL, }); const idMap = new Map(repoIds.map((r) => [r.providerRepositoryId, r.id])); return repos.map((repo) => ({ cloneUrl: repo.clone_url, defaultBranch: repo.default_branch ?? "main", fullName: repo.full_name, gitRepositoryId: idMap.get(String(repo.id)) ?? "", name: repo.name, owner: repo.owner.login, privacy: repo.private ? ("private" as const) : ("public" as const), provider: "github" as const, webUrl: repo.html_url, })); }, });