From 3e3f06403fed02d02aa8e706139c6737871f8780 Mon Sep 17 00:00:00 2001 From: -Puter <22245429+puterhimself@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:17:45 +0530 Subject: [PATCH] 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. --- .../backend/convex/gitConnectionHealth.ts | 98 +++++++- packages/backend/convex/gitConnections.ts | 236 +++++++++++++++++- packages/backend/convex/gitProvisioning.ts | 17 +- packages/env/src/convex.ts | 1 - 4 files changed, 335 insertions(+), 17 deletions(-) diff --git a/packages/backend/convex/gitConnectionHealth.ts b/packages/backend/convex/gitConnectionHealth.ts index bf8332f..06404a4 100644 --- a/packages/backend/convex/gitConnectionHealth.ts +++ b/packages/backend/convex/gitConnectionHealth.ts @@ -158,11 +158,6 @@ export const updateConnectionState = internalMutation({ }, }); -const getConnectionForOwnerRef = makeFunctionReference< - "query", - { connectionId: Id<"gitConnections"> }, - Doc<"gitConnections"> | null ->("gitConnectionHealth:getConnectionForOwner"); const getConnectionRef = makeFunctionReference< "query", @@ -175,6 +170,11 @@ const getStaleConnectionsRef = makeFunctionReference< Record, { connectionId: Id<"gitConnections"> }[] >("gitConnectionHealth:getStaleConnections"); +const resolvePersonalOrgRef = makeFunctionReference< + "query", + { userId: string }, + Id<"organizations"> | null +>("gitConnections:resolvePersonalOrg"); const updateConnectionStateRef = makeFunctionReference< "mutation", @@ -203,6 +203,19 @@ export const getConnection = internalQuery({ 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) => { @@ -225,10 +238,21 @@ export const getStaleConnections = internalQuery({ export const verify = action({ args: { connectionId: v.id("gitConnections") }, 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, }); - if (!connection) { + if (!connection || connection.organizationId !== organizationId) { throw new ConvexError("Git connection not found"); } const result = await verifyCredential(connection); @@ -284,3 +308,63 @@ export const reconcileStaleConnections = action({ 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; + }, +}); \ No newline at end of file diff --git a/packages/backend/convex/gitConnections.ts b/packages/backend/convex/gitConnections.ts index 47109c6..8536902 100644 --- a/packages/backend/convex/gitConnections.ts +++ b/packages/backend/convex/gitConnections.ts @@ -13,8 +13,13 @@ import { Effect } from "effect"; import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import { action } from "./_generated/server"; +import type { ActionCtx } from "./_generated/server"; 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 => { if (!env.GIT_CREDENTIAL_ENCRYPTION_KEY) { throw new ConvexError("Git credential encryption is not configured"); @@ -189,12 +194,17 @@ export const connectGithub = action({ } const externalUser = await fetchGithubUser(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( internal.gitConnectionData.persist, { ...encrypted, ...externalUser, credentialKind: "oauth", + grantedScopesJson, provider: "github", serverUrl: GITHUB_SERVER_URL, userId: identity.tokenIdentifier, @@ -208,14 +218,20 @@ export const connectGithub = action({ }, }); -const getConnectionForOwnerSyncRef = makeFunctionReference< +const getConnectionRef = makeFunctionReference< "query", { connectionId: Id<"gitConnections"> }, Doc<"gitConnections"> | null ->("gitConnectionHealth:getConnectionForOwner"); +>("gitConnectionHealth:getConnection"); +const resolvePersonalOrgRef = makeFunctionReference< + "query", + { userId: string }, + Id<"organizations"> | null +>("gitConnectionHealth:resolvePersonalOrg"); const syncRepositoriesBatchRef = makeFunctionReference< "mutation", { + organizationId: Id<"organizations">; providerAccountId: Id<"gitProviderAccounts">; provider: "github" | "gitea"; repos: { @@ -233,14 +249,43 @@ const syncRepositoriesBatchRef = makeFunctionReference< }, number >("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({ args: { connectionId: v.id("gitConnections") }, 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, }); - if (!connection) { + if (!connection || connection.organizationId !== organizationId) { throw new ConvexError("Git connection not found"); } const token = await decryptCredential( @@ -290,6 +335,7 @@ export const syncRepositories = action({ } const synced = await ctx.runMutation(syncRepositoriesBatchRef, { + organizationId, provider: connection.provider, providerAccountId: connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">), @@ -309,3 +355,185 @@ export const syncRepositories = action({ 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 => { + 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, + })); + }, +}); diff --git a/packages/backend/convex/gitProvisioning.ts b/packages/backend/convex/gitProvisioning.ts index 66e0447..822e60b 100644 --- a/packages/backend/convex/gitProvisioning.ts +++ b/packages/backend/convex/gitProvisioning.ts @@ -193,9 +193,9 @@ const giteaAdminHeaders = (): Record => { const GITEA_API = `${PUTER_GIT_SERVER_URL}/api/v1`; /** - * Ensure a Gitea webhook is configured for a Puter repository. Creates a - * webhook targeting the Convex HTTP action endpoint if none exists. - * Throws on any failure so the caller knows webhook setup did not succeed. + * Ensure a Gitea webhook is configured for a Puter repository. The webhook + * must address the Convex Site HTTP action directly: `SITE_URL` is the web + * origin and does not proxy this endpoint in production. */ const ensurePuterWebhook = async ( owner: string, @@ -206,7 +206,7 @@ const ensurePuterWebhook = async ( "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. const listResponse = await fetch( `${GITEA_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/webhooks`, @@ -1384,6 +1384,7 @@ export const listProviderAccounts = query({ return { externalUsername, + grantedScopesJson: connection.grantedScopesJson, id: String(connection._id), provider: connection.provider, serverUrl: connection.serverUrl, @@ -1505,6 +1506,7 @@ export const attachRepositoryToProject = internalMutation({ export const syncRepositoriesBatch = internalMutation({ args: { + organizationId: v.id("organizations"), provider: v.union(v.literal("github"), v.literal("gitea")), providerAccountId: v.id("gitProviderAccounts"), repos: v.array( @@ -1523,7 +1525,12 @@ export const syncRepositoriesBatch = internalMutation({ serverUrl: v.string(), }, 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; for (const repo of args.repos) { const existing = await ctx.db diff --git a/packages/env/src/convex.ts b/packages/env/src/convex.ts index f0bb2a6..05c8b1c 100644 --- a/packages/env/src/convex.ts +++ b/packages/env/src/convex.ts @@ -6,7 +6,6 @@ export const env = createEnv({ runtimeEnv: process.env, server: { AGENT_BACKEND_URL: z.url().optional(), - CONVEX_SITE_URL: z.url(), FLUE_DB_TOKEN: z.string().min(1), FLUE_URL: z.url().optional(), GITEA_TOKEN: z.string().min(1).optional(),