- 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
367 lines
11 KiB
TypeScript
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;
|
|
},
|
|
});
|