"use node"; 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"; import { decryptCredential } from "./gitConnections"; interface VerifyResult { readonly lastError?: string; readonly state: "active" | "reauth-required" | "unavailable"; } 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 getConnectionForOwnerRef = makeFunctionReference< "query", { connectionId: Id<"gitConnections"> }, Doc<"gitConnections"> | null >("gitConnectionHealth:getConnectionForOwner"); const getConnectionRef = makeFunctionReference< "query", { connectionId: Id<"gitConnections"> }, Doc<"gitConnections"> | null >("gitConnectionHealth:getConnection"); const getStaleConnectionsRef = makeFunctionReference< "query", Record, { connectionId: Id<"gitConnections"> }[] >("gitConnectionHealth:getStaleConnections"); 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 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) => { const connection = await ctx.runQuery(getConnectionForOwnerRef, { connectionId: args.connectionId, }); if (!connection) { 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 }; }, });