"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 { authComponent, createAuth } from "./auth"; 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; readonly id: number; readonly login: string; }; return { externalAccountId: String(user.id), externalEmail: user.email, 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; readonly id: number; readonly login: string; }; return { externalAccountId: String(user.id), externalEmail: user.email, 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 connectionId = await ctx.runMutation( internal.gitConnectionData.persist, { ...encrypted, ...externalUser, credentialKind: "oauth", provider: "github", serverUrl: GITHUB_SERVER_URL, userId: identity.tokenIdentifier, } ); // Sync accessible repositories after connecting. await ctx.runAction(syncRepositoriesRef, { connectionId, }); return { connectionId }; }, }); const getConnectionForOwnerSyncRef = makeFunctionReference< "query", { connectionId: Id<"gitConnections"> }, Doc<"gitConnections"> | null >("gitConnectionHealth:getConnectionForOwner"); const syncRepositoriesBatchRef = makeFunctionReference< "mutation", { 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"); export const syncRepositories = action({ args: { connectionId: v.id("gitConnections") }, handler: async (ctx, args): Promise<{ synced: number }> => { const connection = await ctx.runQuery(getConnectionForOwnerSyncRef, { connectionId: args.connectionId, }); if (!connection) { 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, { 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 }; }, });