Files
zopu-code/packages/backend/convex/gitConnectionHealth.ts
-Puter db9afd7a24 fix(lint): resolve all lint errors in github search and settings UI
- Merge duplicate react imports in repository-selector
- Use <dialog open> instead of role=dialog in settings popover
- Export type re-export from source module in use-github-repo-search
- Sort action args alphabetically in searchGithubRepositories
- Disable react-compiler rule for synchronous setState in debounced search
- Simplify getActiveGithubConnection query
2026-08-01 20:38:57 +05:30

367 lines
11 KiB
TypeScript

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<ArrayBuffer> => {
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<CryptoKey> => {
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<string> => {
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<VerifyResult> => {
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<VerifyResult> => {
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<VerifyResult> => {
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<string, unknown> = {
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<string, never>,
{ 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<Id<"organizations"> | 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<boolean> => {
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.eq(q.field("provider"), "github"))
.collect();
// Prefer an active connection; fall back to any GitHub connection.
const active =
connections.find((c) => c.state === "active") ??
connections.find((c) => c.state === undefined) ??
connections[0] ??
null;
return active;
},
});
/**
* 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;
},
});