import { env } from "@code/env/convex"; import { CREDENTIAL_FRESHNESS_MS, isCredentialFresh, } from "@code/primitives/git-provider"; import { makeFunctionReference } from "convex/server"; import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { action, internalMutation, internalQuery, query, } from "./_generated/server"; import { requireCurrentOrganization } from "./authz"; interface VerifyResult { readonly lastError?: string; readonly state: "active" | "reauth-required" | "unavailable"; } const decodeBase64url = (value: string): Uint8Array => { const padded = value .replaceAll("-", "+") .replaceAll("_", "/") .padEnd(Math.ceil(value.length / 4) * 4, "="); const decoded = atob(padded); const bytes = new Uint8Array(decoded.length); for (let index = 0; index < decoded.length; index += 1) { const codePoint = decoded.codePointAt(index); if (codePoint === undefined) { throw new ConvexError("Invalid credential encoding"); } bytes[index] = codePoint; } return bytes; }; const credentialEncryptionKey = async (): Promise => { if (!env.GIT_CREDENTIAL_ENCRYPTION_KEY) { throw new ConvexError("Git credential encryption is not configured"); } const bytes = decodeBase64url(env.GIT_CREDENTIAL_ENCRYPTION_KEY); 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, [ "decrypt", ]); }; const decryptCredential = async ( credentialCiphertext: string, credentialIv: string ): Promise => { const decrypted = await crypto.subtle.decrypt( { iv: decodeBase64url(credentialIv), name: "AES-GCM", }, await credentialEncryptionKey(), decodeBase64url(credentialCiphertext) ); return new TextDecoder().decode(decrypted); }; const verifyGiteaCredential = async ( serverUrl: string, token: string ): Promise => { try { const response = await fetch( `${serverUrl.replace(/\/+$/u, "")}/api/v1/user`, { headers: { authorization: `token ${token}` } } ); if (response.status === 401 || response.status === 403) { return { lastError: "Token rejected", state: "reauth-required" }; } if (!response.ok) { return { lastError: `Provider returned ${response.status}`, state: "unavailable", }; } return { state: "active" }; } catch (error) { return { lastError: error instanceof Error ? error.message : "Unreachable", state: "unavailable", }; } }; const verifyGithubCredential = async (token: string): Promise => { try { const response = await fetch("https://api.github.com/user", { headers: { accept: "application/vnd.github+json", authorization: `Bearer ${token}`, }, }); if (response.status === 401 || response.status === 403) { return { lastError: "Token rejected", state: "reauth-required" }; } if (!response.ok) { return { lastError: `Provider returned ${response.status}`, state: "unavailable", }; } return { state: "active" }; } catch (error) { return { lastError: error instanceof Error ? error.message : "Unreachable", state: "unavailable", }; } }; export const verifyCredential = async ( connection: Doc<"gitConnections"> ): Promise => { const credential = await decryptCredential( connection.credentialCiphertext, connection.credentialIv ); return connection.provider === "gitea" ? verifyGiteaCredential(connection.serverUrl, credential) : verifyGithubCredential(credential); }; export const updateConnectionState = internalMutation({ args: { connectionId: v.id("gitConnections"), lastError: v.optional(v.string()), state: v.union( v.literal("active"), v.literal("reauth-required"), v.literal("unavailable") ), }, handler: async (ctx, args) => { const timestamp = Date.now(); const patch: Record = { lastError: args.lastError, state: args.state, updatedAt: timestamp, }; if (args.state === "active") { patch.lastError = undefined; patch.lastVerifiedAt = timestamp; patch.reauthRequiredAt = undefined; } else if (args.state === "reauth-required") { patch.reauthRequiredAt = timestamp; } await ctx.db.patch(args.connectionId, patch); }, }); const getConnectionRef = makeFunctionReference< "query", { connectionId: Id<"gitConnections"> }, Doc<"gitConnections"> | null >("gitConnectionHealth:getConnection"); const getStaleConnectionsRef = makeFunctionReference< "query", Record, { connectionId: Id<"gitConnections"> }[] >("gitConnectionHealth:getStaleConnections"); const resolvePersonalOrgRef = makeFunctionReference< "query", { userId: string }, Id<"organizations"> | null >("gitConnections:resolvePersonalOrg"); const updateConnectionStateRef = makeFunctionReference< "mutation", { connectionId: Id<"gitConnections">; lastError?: string; state: "active" | "reauth-required" | "unavailable"; }, null >("gitConnectionHealth:updateConnectionState"); export const getConnectionForOwner = internalQuery({ args: { connectionId: v.id("gitConnections") }, handler: async (ctx, args) => { const { organizationId } = await requireCurrentOrganization(ctx); const connection = await ctx.db.get(args.connectionId); if (!connection || connection.organizationId !== organizationId) { return null; } return connection; }, }); export const getConnection = internalQuery({ args: { connectionId: v.id("gitConnections") }, handler: async (ctx, args) => await ctx.db.get(args.connectionId), }); export const resolvePersonalOrg = internalQuery({ args: { userId: v.string() }, handler: async (ctx, args): Promise | null> => { const org = await ctx.db .query("organizations") .withIndex("by_createdBy_and_kind", (q) => q.eq("createdBy", args.userId).eq("kind", "personal") ) .unique(); return org?._id ?? null; }, }); export const getStaleConnections = internalQuery({ args: {}, handler: async (ctx) => { const now = Date.now(); const cutoff = now - CREDENTIAL_FRESHNESS_MS; const all = await ctx.db.query("gitConnections").collect(); return all .filter( (conn) => (conn.state === "active" && (conn.lastVerifiedAt === undefined || conn.lastVerifiedAt < cutoff)) || conn.state === "reauth-required" || conn.state === undefined ) .map((conn) => ({ connectionId: conn._id })); }, }); export const verify = action({ args: { connectionId: v.id("gitConnections") }, handler: async (ctx, args) => { // Resolve identity in the action; sub-queries via runQuery don't inherit it. 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 result = await verifyCredential(connection); await ctx.runMutation(updateConnectionStateRef, { connectionId: args.connectionId, lastError: result.lastError, state: result.state, }); return result; }, }); export const isFresh = query({ args: { connectionId: v.id("gitConnections") }, handler: async (ctx, args): Promise => { const { organizationId } = await requireCurrentOrganization(ctx); const connection = await ctx.db.get(args.connectionId); if (!connection || connection.organizationId !== organizationId) { throw new ConvexError("Git connection not found"); } return isCredentialFresh(connection.lastVerifiedAt, Date.now()); }, }); export const reconcileStaleConnections = action({ args: {}, handler: async (ctx) => { const stale = await ctx.runQuery(getStaleConnectionsRef, {}); let checked = 0; for (const { connectionId } of stale) { const connection = await ctx.runQuery(getConnectionRef, { connectionId, }); if (!connection) { continue; } try { const result = await verifyCredential(connection); await ctx.runMutation(updateConnectionStateRef, { connectionId, lastError: result.lastError, state: result.state, }); checked += 1; } catch { await ctx.runMutation(updateConnectionStateRef, { connectionId, lastError: "Verification failed", state: "unavailable", }); } } return { checked }; }, }); /** * Returns the active GitHub connection for an organization (used by the * live-repo-search action when no explicit connectionId is supplied). */ export const getActiveGithubConnection = internalQuery({ args: { organizationId: v.id("organizations") }, handler: async (ctx, args) => { const connections = await ctx.db .query("gitConnections") .withIndex("by_organizationId", (q) => q.eq("organizationId", args.organizationId) ) .filter((q) => q.and( q.eq(q.field("provider"), "github"), q.or( q.eq(q.field("state"), "active"), q.eq(q.field("state"), undefined) ) ) ) .first(); return connections ?? null; }, }); /** * Resolves gitRepositoryIds for a set of external GitHub repository IDs * (used by live search to return stable Convex IDs after upserting results). */ export const getGithubRepoIdsByExternalIds = internalQuery({ args: { organizationId: v.id("organizations"), providerRepositoryIds: v.array(v.string()), serverUrl: v.string(), }, handler: async (ctx, args) => { const results: { id: string; providerRepositoryId: string }[] = []; for (const providerRepositoryId of args.providerRepositoryIds) { const existing = await ctx.db .query("gitRepositories") .withIndex("by_provider_and_serverUrl_and_externalRepositoryId", (q) => q .eq("provider", "github") .eq("serverUrl", args.serverUrl) .eq("providerRepositoryId", providerRepositoryId) ) .unique(); if (existing) { results.push({ id: String(existing._id), providerRepositoryId, }); } } return results; }, });