fix(backend): github connection identity and live repo search

Resolve identity in actions and pass organizationId into sub-queries,
since runQuery from an action does not propagate auth. Add live GitHub
repository search backed by the stored encrypted credential, persisting
results into gitRepositories. Webhook now targets CONVEX_SITE_URL
directly instead of the web origin.
This commit is contained in:
-Puter
2026-08-01 20:17:45 +05:30
parent 8bd3953440
commit 3e3f06403f
4 changed files with 335 additions and 17 deletions

View File

@@ -158,11 +158,6 @@ export const updateConnectionState = internalMutation({
}, },
}); });
const getConnectionForOwnerRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnectionForOwner");
const getConnectionRef = makeFunctionReference< const getConnectionRef = makeFunctionReference<
"query", "query",
@@ -175,6 +170,11 @@ const getStaleConnectionsRef = makeFunctionReference<
Record<string, never>, Record<string, never>,
{ connectionId: Id<"gitConnections"> }[] { connectionId: Id<"gitConnections"> }[]
>("gitConnectionHealth:getStaleConnections"); >("gitConnectionHealth:getStaleConnections");
const resolvePersonalOrgRef = makeFunctionReference<
"query",
{ userId: string },
Id<"organizations"> | null
>("gitConnections:resolvePersonalOrg");
const updateConnectionStateRef = makeFunctionReference< const updateConnectionStateRef = makeFunctionReference<
"mutation", "mutation",
@@ -203,6 +203,19 @@ export const getConnection = internalQuery({
handler: async (ctx, args) => await ctx.db.get(args.connectionId), 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({ export const getStaleConnections = internalQuery({
args: {}, args: {},
handler: async (ctx) => { handler: async (ctx) => {
@@ -225,10 +238,21 @@ export const getStaleConnections = internalQuery({
export const verify = action({ export const verify = action({
args: { connectionId: v.id("gitConnections") }, args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const connection = await ctx.runQuery(getConnectionForOwnerRef, { // 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, connectionId: args.connectionId,
}); });
if (!connection) { if (!connection || connection.organizationId !== organizationId) {
throw new ConvexError("Git connection not found"); throw new ConvexError("Git connection not found");
} }
const result = await verifyCredential(connection); const result = await verifyCredential(connection);
@@ -284,3 +308,63 @@ export const reconcileStaleConnections = action({
return { checked }; 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;
},
});

View File

@@ -13,8 +13,13 @@ import { Effect } from "effect";
import { internal } from "./_generated/api"; import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import { action } from "./_generated/server"; import { action } from "./_generated/server";
import type { ActionCtx } from "./_generated/server";
import { authComponent, createAuth } from "./auth"; import { authComponent, createAuth } from "./auth";
const MIN_SEARCH_QUERY_LENGTH = 2;
const DEFAULT_SEARCH_PER_PAGE = 20;
const MAX_SEARCH_PER_PAGE = 50;
const encryptionKey = async (): Promise<CryptoKey> => { const encryptionKey = async (): Promise<CryptoKey> => {
if (!env.GIT_CREDENTIAL_ENCRYPTION_KEY) { if (!env.GIT_CREDENTIAL_ENCRYPTION_KEY) {
throw new ConvexError("Git credential encryption is not configured"); throw new ConvexError("Git credential encryption is not configured");
@@ -189,12 +194,17 @@ export const connectGithub = action({
} }
const externalUser = await fetchGithubUser(token.accessToken); const externalUser = await fetchGithubUser(token.accessToken);
const encrypted = await encryptCredential(token.accessToken); const encrypted = await encryptCredential(token.accessToken);
const grantedScopesJson =
token.scopes && token.scopes.length > 0
? JSON.stringify(token.scopes)
: undefined;
const connectionId = await ctx.runMutation( const connectionId = await ctx.runMutation(
internal.gitConnectionData.persist, internal.gitConnectionData.persist,
{ {
...encrypted, ...encrypted,
...externalUser, ...externalUser,
credentialKind: "oauth", credentialKind: "oauth",
grantedScopesJson,
provider: "github", provider: "github",
serverUrl: GITHUB_SERVER_URL, serverUrl: GITHUB_SERVER_URL,
userId: identity.tokenIdentifier, userId: identity.tokenIdentifier,
@@ -208,14 +218,20 @@ export const connectGithub = action({
}, },
}); });
const getConnectionForOwnerSyncRef = makeFunctionReference< const getConnectionRef = makeFunctionReference<
"query", "query",
{ connectionId: Id<"gitConnections"> }, { connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnectionForOwner"); >("gitConnectionHealth:getConnection");
const resolvePersonalOrgRef = makeFunctionReference<
"query",
{ userId: string },
Id<"organizations"> | null
>("gitConnectionHealth:resolvePersonalOrg");
const syncRepositoriesBatchRef = makeFunctionReference< const syncRepositoriesBatchRef = makeFunctionReference<
"mutation", "mutation",
{ {
organizationId: Id<"organizations">;
providerAccountId: Id<"gitProviderAccounts">; providerAccountId: Id<"gitProviderAccounts">;
provider: "github" | "gitea"; provider: "github" | "gitea";
repos: { repos: {
@@ -233,14 +249,43 @@ const syncRepositoriesBatchRef = makeFunctionReference<
}, },
number number
>("gitProvisioning:syncRepositoriesBatch"); >("gitProvisioning:syncRepositoriesBatch");
const getActiveGithubConnectionRef = makeFunctionReference<
"query",
{ organizationId: Id<"organizations"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getActiveGithubConnection");
const getGithubRepoIdsByExternalIdsRef = makeFunctionReference<
"query",
{
organizationId: Id<"organizations">;
providerRepositoryIds: string[];
serverUrl: string;
},
{ id: string; providerRepositoryId: string }[]
>("gitConnectionHealth:getGithubRepoIdsByExternalIds");
export const syncRepositories = action({ export const syncRepositories = action({
args: { connectionId: v.id("gitConnections") }, args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args): Promise<{ synced: number }> => { handler: async (ctx, args): Promise<{ synced: number }> => {
const connection = await ctx.runQuery(getConnectionForOwnerSyncRef, { // Actions have the authenticated identity, but `ctx.runQuery`/`runMutation`
// invoked from an action do NOT propagate it — so `requireCurrentOrganization`
// inside the sub-functions would see no identity. Resolve the identity here
// and pass the organizationId down explicitly.
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, connectionId: args.connectionId,
}); });
if (!connection) { if (!connection || connection.organizationId !== organizationId) {
throw new ConvexError("Git connection not found"); throw new ConvexError("Git connection not found");
} }
const token = await decryptCredential( const token = await decryptCredential(
@@ -290,6 +335,7 @@ export const syncRepositories = action({
} }
const synced = await ctx.runMutation(syncRepositoriesBatchRef, { const synced = await ctx.runMutation(syncRepositoriesBatchRef, {
organizationId,
provider: connection.provider, provider: connection.provider,
providerAccountId: providerAccountId:
connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">), connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">),
@@ -309,3 +355,185 @@ export const syncRepositories = action({
return { synced }; return { synced };
}, },
}); });
// ---------------------------------------------------------------------------
// Live GitHub repository search
// ---------------------------------------------------------------------------
interface GithubSearchRepo {
readonly clone_url: string;
readonly default_branch: string | null;
readonly full_name: string;
readonly html_url: string;
readonly id: number;
readonly name: string;
readonly owner: { login: string };
readonly private: boolean;
}
const resolveActiveGithubConnection = async (
ctx: ActionCtx,
args: { connectionId?: Id<"gitConnections"> }
): Promise<{
connection: Doc<"gitConnections">;
organizationId: Id<"organizations">;
token: string;
username: string;
}> => {
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");
}
// If no explicit connection given, find the active GitHub connection.
const connection = args.connectionId
? await ctx.runQuery(getConnectionRef, { connectionId: args.connectionId })
: await ctx.runQuery(getActiveGithubConnectionRef, { organizationId });
if (!connection || connection.organizationId !== organizationId) {
throw new ConvexError("Git connection not found");
}
if (connection.provider !== "github") {
throw new ConvexError("This action requires a GitHub connection");
}
if (connection.state !== undefined && connection.state !== "active") {
throw new ConvexError(
`GitHub connection is ${connection.state}; reconnect before searching`
);
}
const token = await decryptCredential(
connection.credentialCiphertext,
connection.credentialIv
);
const username =
connection.username ??
(await fetchGithubUser(token)).externalUsername;
return { connection, organizationId, token, username };
};
export interface GithubRepositorySearchResult {
readonly cloneUrl: string;
readonly defaultBranch: string;
readonly fullName: string;
readonly gitRepositoryId: string;
readonly name: string;
readonly owner: string;
readonly privacy: "public" | "private";
readonly provider: "github";
readonly webUrl: string;
}
/**
* Live GitHub repository search using the authenticated user's stored
* encrypted credential. Searches across the user's accessible repos (owned,
* collaborator, and org member) via the GitHub search API, then upserts the
* results into gitRepositories so createProjectFromRepository works unchanged.
*/
export const searchGithubRepositories = action({
args: {
query: v.string(),
connectionId: v.optional(v.id("gitConnections")),
perPage: v.optional(v.number()),
},
handler: async (
ctx,
args
): Promise<readonly GithubRepositorySearchResult[]> => {
const query = args.query.trim();
if (query.length < MIN_SEARCH_QUERY_LENGTH) {
return [];
}
const { connection, organizationId, token, username } =
await resolveActiveGithubConnection(ctx, args);
// GitHub search: scope to repos accessible by this user. Using `user:` and
// `org:` qualifiers would require enumerating orgs first. The search API
// with the token naturally returns only repos the user has access to.
const perPage = Math.min(
Math.max(args.perPage ?? DEFAULT_SEARCH_PER_PAGE, 1),
MAX_SEARCH_PER_PAGE
);
// Build query: match name/description, restrict to repos the user can see.
// The `@USER` qualifier isn't valid; we use `user:` for their personal
// repos plus broad name search which GitHub filters to accessible repos.
const searchQuery = encodeURIComponent(
`${query} user:${username}`
).replace(/%3A/gu, ":");
const response = await fetch(
`https://api.github.com/search/repositories?q=${searchQuery}&per_page=${perPage}&sort=updated`,
{
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
}
);
if (response.status === 401 || response.status === 403) {
throw new ConvexError(
"GitHub rejected the stored token; reauthorize the connection"
);
}
if (!response.ok) {
throw new ConvexError(
`GitHub search failed (${response.status})`
);
}
const body = (await response.json()) as {
items: GithubSearchRepo[];
total_count: number;
};
const repos = body.items ?? [];
if (repos.length === 0) {
return [];
}
// Persist search results into gitRepositories so the existing
// createProjectFromRepository mutation works unchanged.
await ctx.runMutation(syncRepositoriesBatchRef, {
organizationId,
provider: "github",
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: GITHUB_SERVER_URL,
webUrl: repo.html_url,
})),
serverUrl: GITHUB_SERVER_URL,
});
// Read back the upserted repos so we return stable gitRepositoryIds.
const repoIds = await ctx.runQuery(getGithubRepoIdsByExternalIdsRef, {
organizationId,
providerRepositoryIds: repos.map((repo) => String(repo.id)),
serverUrl: GITHUB_SERVER_URL,
});
const idMap = new Map(repoIds.map((r) => [r.providerRepositoryId, r.id]));
return repos.map((repo) => ({
cloneUrl: repo.clone_url,
defaultBranch: repo.default_branch ?? "main",
fullName: repo.full_name,
gitRepositoryId: idMap.get(String(repo.id)) ?? "",
name: repo.name,
owner: repo.owner.login,
privacy: repo.private ? ("private" as const) : ("public" as const),
provider: "github" as const,
webUrl: repo.html_url,
}));
},
});

View File

@@ -193,9 +193,9 @@ const giteaAdminHeaders = (): Record<string, string> => {
const GITEA_API = `${PUTER_GIT_SERVER_URL}/api/v1`; const GITEA_API = `${PUTER_GIT_SERVER_URL}/api/v1`;
/** /**
* Ensure a Gitea webhook is configured for a Puter repository. Creates a * Ensure a Gitea webhook is configured for a Puter repository. The webhook
* webhook targeting the Convex HTTP action endpoint if none exists. * must address the Convex Site HTTP action directly: `SITE_URL` is the web
* Throws on any failure so the caller knows webhook setup did not succeed. * origin and does not proxy this endpoint in production.
*/ */
const ensurePuterWebhook = async ( const ensurePuterWebhook = async (
owner: string, owner: string,
@@ -206,7 +206,7 @@ const ensurePuterWebhook = async (
"GITEA_WEBHOOK_SECRET is not configured; cannot create Puter webhook" "GITEA_WEBHOOK_SECRET is not configured; cannot create Puter webhook"
); );
} }
const webhookUrl = `${env.SITE_URL}/api/git/webhooks/puter`; const webhookUrl = `${env.CONVEX_SITE_URL.replace(/\/+$/u, "")}/api/git/webhooks/puter`;
// Check if a webhook already exists for this repo. // Check if a webhook already exists for this repo.
const listResponse = await fetch( const listResponse = await fetch(
`${GITEA_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/webhooks`, `${GITEA_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/webhooks`,
@@ -1384,6 +1384,7 @@ export const listProviderAccounts = query({
return { return {
externalUsername, externalUsername,
grantedScopesJson: connection.grantedScopesJson,
id: String(connection._id), id: String(connection._id),
provider: connection.provider, provider: connection.provider,
serverUrl: connection.serverUrl, serverUrl: connection.serverUrl,
@@ -1505,6 +1506,7 @@ export const attachRepositoryToProject = internalMutation({
export const syncRepositoriesBatch = internalMutation({ export const syncRepositoriesBatch = internalMutation({
args: { args: {
organizationId: v.id("organizations"),
provider: v.union(v.literal("github"), v.literal("gitea")), provider: v.union(v.literal("github"), v.literal("gitea")),
providerAccountId: v.id("gitProviderAccounts"), providerAccountId: v.id("gitProviderAccounts"),
repos: v.array( repos: v.array(
@@ -1523,7 +1525,12 @@ export const syncRepositoriesBatch = internalMutation({
serverUrl: v.string(), serverUrl: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const { organizationId } = await requireCurrentOrganization(ctx); // `organizationId` is passed explicitly because this internal mutation is
// invoked from an action via `ctx.runMutation`, which does not propagate
// the authenticated identity (so `ctx.auth.getUserIdentity()` is null).
// The caller (`syncRepositories`) resolves the identity in the action and
// verifies the connection belongs to the caller before reaching here.
const { organizationId } = args;
let upserted = 0; let upserted = 0;
for (const repo of args.repos) { for (const repo of args.repos) {
const existing = await ctx.db const existing = await ctx.db

View File

@@ -6,7 +6,6 @@ export const env = createEnv({
runtimeEnv: process.env, runtimeEnv: process.env,
server: { server: {
AGENT_BACKEND_URL: z.url().optional(), AGENT_BACKEND_URL: z.url().optional(),
CONVEX_SITE_URL: z.url(),
FLUE_DB_TOKEN: z.string().min(1), FLUE_DB_TOKEN: z.string().min(1),
FLUE_URL: z.url().optional(), FLUE_URL: z.url().optional(),
GITEA_TOKEN: z.string().min(1).optional(), GITEA_TOKEN: z.string().min(1).optional(),