import { env } from "@code/env/convex"; import { CREDENTIAL_FRESHNESS_MS, PUTER_GIT_SERVER_URL, } from "@code/primitives/git-provider"; import { assertOwnerSafeProvisioningInput, idempotencyKeyFor, validateProjectInstructions, validatePuterOrgName, validatePuterUsername, validateRepositoryName, } from "@code/primitives/git-provisioning"; import { makeFunctionReference } from "convex/server"; import { ConvexError, v } from "convex/values"; import { Effect } from "effect"; import type { Doc, Id } from "./_generated/dataModel"; import { action, internalAction, internalMutation, internalQuery, mutation, query, } from "./_generated/server"; import { authComponent, createAuth } from "./auth"; import { requireCurrentOrganization, requireCurrentOrganizationOwner, } from "./authz"; // --------------------------------------------------------------------------- // Internal function references (codegen not regenerated without a deployment) // --------------------------------------------------------------------------- /* eslint-disable no-use-before-define -- backend module with many interdependent helpers */ const persistPuterUserAccountRef = makeFunctionReference< "mutation", { betterAuthEmail: string; betterAuthName: string; externalUserId: string; organizationId: Id<"organizations">; puterUsername: string; userId: string; }, Id<"gitProviderAccounts"> >("gitProvisioning:persistPuterUserAccount"); const findMigrationRef = makeFunctionReference< "query", { idempotencyKey: string }, Doc<"gitMigrations"> | null >("gitProvisioning:findMigration"); const createMigrationRef = makeFunctionReference< "mutation", { githubConnectionId: Id<"gitConnections">; idempotencyKey: string; includeLfs: boolean; organizationId: Id<"organizations">; puterConnectionId: Id<"gitConnections">; sourceIsPrivate: boolean; sourceRepositoryUrl: string; targetOwner: string; targetRepositoryName: string; }, Id<"gitMigrations"> >("gitProvisioning:createMigration"); const updateMigrationStatusRef = makeFunctionReference< "mutation", { failureReason?: string; migrationId: Id<"gitMigrations">; resultingRepositoryId?: Id<"gitRepositories">; status: "queued" | "running" | "succeeded" | "failed" | "cancelled"; }, null >("gitProvisioning:updateMigrationStatus"); const upsertRepositoryRef = makeFunctionReference< "mutation", { cloneUrl: string; defaultBranch: string; fullName: string; gitProviderAccountId: Id<"gitProviderAccounts">; lfsCapability: "unknown" | "supported" | "unsupported"; name: string; organizationId: Id<"organizations">; owner: string; permissionsAdmin: boolean; permissionsMaintain: boolean; permissionsPull: boolean; permissionsPush: boolean; permissionsTriage: boolean; privacy: "public" | "private"; provider: "github" | "gitea"; providerRepositoryId: string; serverUrl: string; sourceMigrationId?: Id<"gitMigrations">; webUrl: string; }, Id<"gitRepositories"> >("gitProvisioning:upsertRepository"); const getConnectionRef = makeFunctionReference< "query", { connectionId: Id<"gitConnections"> }, Doc<"gitConnections"> | null >("gitProvisioning:getConnection"); const findProviderAccountRef = makeFunctionReference< "query", { providerAccountId: Id<"gitProviderAccounts"> }, Doc<"gitProviderAccounts"> | null >("gitProvisioning:findProviderAccount"); const persistPuterOrgRef = makeFunctionReference< "mutation", { displayName?: string; externalOrgId: string; gitProviderAccountId: Id<"gitProviderAccounts">; organizationId: Id<"organizations">; puterOrgName: string; }, Id<"gitProviderOrganizations"> >("gitProvisioning:persistPuterOrg"); const findProviderOrgByAccountRef = makeFunctionReference< "query", { externalSlug: string; gitProviderAccountId: Id<"gitProviderAccounts">; }, { _id: Id<"gitProviderOrganizations">; externalSlug: string } | null >("gitProvisioning:findProviderOrgByAccount"); const currentOrgForOwnerRef = makeFunctionReference< "query", Record, { organizationId: Id<"organizations">; userId: string } >("gitProvisioning:resolveCurrentOrgForOwner"); const updateWebhookStateRef = makeFunctionReference< "mutation", { repositoryId: Id<"gitRepositories">; webhookState: "none" | "pending" | "active" | "failed"; }, null >("gitProvisioning:updateWebhookState"); // --------------------------------------------------------------------------- // Better Auth user info helper // --------------------------------------------------------------------------- interface BetterAuthUserInfo { readonly email: string; readonly name: string; } const getBetterAuthUser = async (ctx: unknown): Promise => { const { auth, headers } = await authComponent.getAuth( createAuth, ctx as never ); const session = await auth.api.getSession({ headers }); if (!session || !session.user) { throw new ConvexError("Authentication required"); } return { email: session.user.email, name: session.user.name, }; }; // --------------------------------------------------------------------------- // Gitea admin API helpers // --------------------------------------------------------------------------- const giteaAdminHeaders = (): Record => { if (!env.PUTER_GIT_ADMIN_TOKEN) { throw new ConvexError("PUTER_GIT_ADMIN_TOKEN is not configured"); } return { authorization: `token ${env.PUTER_GIT_ADMIN_TOKEN}`, "content-type": "application/json", }; }; const GITEA_API = `${PUTER_GIT_SERVER_URL}/api/v1`; /** * 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, repoName: string ): Promise => { if (!env.GITEA_WEBHOOK_SECRET) { throw new ConvexError( "GITEA_WEBHOOK_SECRET is not configured; cannot create Puter webhook" ); } if (!env.CONVEX_SITE_URL) { throw new ConvexError( "CONVEX_SITE_URL is not configured; cannot create Puter webhook" ); } 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`, { headers: giteaAdminHeaders() } ); if (!listResponse.ok) { throw new ConvexError( `Failed to list existing webhooks (${listResponse.status})` ); } const webhooks = (await listResponse.json()) as { readonly url: string; }[]; const exists = webhooks.some((webhook) => webhook.url === webhookUrl); if (exists) { return; } // Create the webhook. const createResponse = await fetch( `${GITEA_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/webhooks`, { body: JSON.stringify({ active: true, content_type: "json", events: ["push", "repository", "create", "delete", "fork"], secret: env.GITEA_WEBHOOK_SECRET, type: "gitea", url: webhookUrl, }), headers: giteaAdminHeaders(), method: "POST", } ); if (!createResponse.ok) { const errorBody = await createResponse.text(); throw new ConvexError( `Failed to create Puter webhook (${createResponse.status}): ${errorBody}` ); } }; // --------------------------------------------------------------------------- // createPuterUser // --------------------------------------------------------------------------- export const createPuterUser = action({ args: { puterUsername: v.string() }, handler: async (ctx, args) => { const { organizationId, userId } = await resolveOwnerContext(ctx); const authUser = await getBetterAuthUser(ctx); // Validate the requested username await Effect.runPromise(validatePuterUsername(args.puterUsername)); // Provision the user via Gitea admin API const tempPassword = generateTempPassword(); const response = await fetch(`${GITEA_API}/admin/users`, { body: JSON.stringify({ change_passwd: true, email: authUser.email, full_name: authUser.name, login_name: args.puterUsername, must_change_password: true, password: tempPassword, send_notify: true, source_id: 0, username: args.puterUsername, }), headers: giteaAdminHeaders(), method: "POST", }); let externalUserId: string; let alreadyExisted = false; if (response.status === 422) { // User may already exist — verify it matches the authenticated email const existingUser = await fetchExistingUserDetails(args.puterUsername); if (existingUser && existingUser.email === authUser.email) { externalUserId = existingUser.id; alreadyExisted = true; } else if (existingUser) { throw new ConvexError( "A different user already owns this Puter username" ); } else { throw new ConvexError( `Gitea user creation rejected (${response.status})` ); } } else if (response.ok) { const created = (await response.json()) as { readonly id: number }; externalUserId = String(created.id); } else { throw new ConvexError(`Gitea user creation failed (${response.status})`); } // Persist the external account as pending-auth const providerAccountId = await ctx.runMutation( persistPuterUserAccountRef, { betterAuthEmail: authUser.email, betterAuthName: authUser.name, externalUserId, organizationId, puterUsername: args.puterUsername, userId, } ); return { alreadyExisted, externalUserId, providerAccountId: String(providerAccountId), puterUsername: args.puterUsername, }; }, }); const fetchExistingUserDetails = async ( username: string ): Promise<{ readonly email: string; readonly id: string } | null> => { const response = await fetch( `${GITEA_API}/users/${encodeURIComponent(username)}`, { headers: giteaAdminHeaders() } ); if (!response.ok) { return null; } const user = (await response.json()) as { readonly email: string; readonly id: number; }; return { email: user.email, id: String(user.id) }; }; const generateTempPassword = (): string => { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let password = ""; const bytes = crypto.getRandomValues(new Uint8Array(32)); for (let i = 0; i < 32; i += 1) { password += chars[bytes[i]! % chars.length]; } return password; }; // --------------------------------------------------------------------------- // createPuterOrganization // --------------------------------------------------------------------------- export const createPuterOrganization = action({ args: { displayName: v.optional(v.string()), providerAccountId: v.id("gitProviderAccounts"), puterOrgName: v.string(), }, handler: async (ctx, args) => { const { organizationId } = await resolveOwnerContext(ctx); await Effect.runPromise(validatePuterOrgName(args.puterOrgName)); const account = await ctx.runQuery(findProviderAccountRef, { providerAccountId: args.providerAccountId, }); if (!account || account.organizationId !== organizationId) { throw new ConvexError("Provider account not found"); } await Effect.runPromise( assertOwnerSafeProvisioningInput( { providerAccountId: account.externalAccountId }, { externalAccountId: account.externalAccountId, externalEmail: account.externalEmail, externalUsername: account.externalUsername, provider: account.provider, serverUrl: account.serverUrl, } ) ); const response = await fetch(`${GITEA_API}/orgs`, { body: JSON.stringify({ description: args.displayName ?? args.puterOrgName, username: args.puterOrgName, visibility: "private", }), headers: giteaAdminHeaders(), method: "POST", }); let externalOrgId: string; let alreadyExisted = false; if (response.status === 422) { const existing = await fetch( `${GITEA_API}/orgs/${encodeURIComponent(args.puterOrgName)}`, { headers: giteaAdminHeaders() } ); if (!existing.ok) { throw new ConvexError( `Gitea org creation rejected (${response.status})` ); } const org = (await existing.json()) as { readonly id: number }; externalOrgId = String(org.id); alreadyExisted = true; } else if (response.ok) { const created = (await response.json()) as { readonly id: number }; externalOrgId = String(created.id); } else { throw new ConvexError(`Gitea org creation failed (${response.status})`); } // Ensure the provider account user is a member of the organization with // owner role. When the admin token creates the org, the admin user is // the default owner but the provider account user is not automatically // added. We must explicitly add them as an owner, otherwise the org // mapping is unsafe to use for repository/migration target binding. const addMemberResponse = await fetch( `${GITEA_API}/orgs/${encodeURIComponent(args.puterOrgName)}/members/${encodeURIComponent(account.externalUsername)}`, { body: JSON.stringify({ role: "admin" }), headers: giteaAdminHeaders(), method: "PUT", } ); // 204 = added; 409 = already a member. Both are acceptable. if (addMemberResponse.status !== 204 && addMemberResponse.status !== 409) { throw new ConvexError( `Failed to add provider account as org owner (${addMemberResponse.status})` ); } // Verify the provider account user is now a member. const verifyMember = await fetch( `${GITEA_API}/orgs/${encodeURIComponent(args.puterOrgName)}/members/${encodeURIComponent(account.externalUsername)}`, { headers: giteaAdminHeaders() } ); if (!verifyMember.ok) { throw new ConvexError( "Could not verify provider account membership after org creation" ); } await ctx.runMutation(persistPuterOrgRef, { displayName: args.displayName, externalOrgId, gitProviderAccountId: args.providerAccountId, organizationId, puterOrgName: args.puterOrgName, }); return { alreadyExisted, externalOrgId, puterOrgName: args.puterOrgName }; }, }); // --------------------------------------------------------------------------- // createPuterRepository // --------------------------------------------------------------------------- export const createPuterRepository = action({ args: { autoInit: v.boolean(), defaultBranch: v.string(), description: v.optional(v.string()), owner: v.string(), privacy: v.union(v.literal("public"), v.literal("private")), providerAccountId: v.id("gitProviderAccounts"), repositoryName: v.string(), }, handler: async (ctx, args) => { const { organizationId } = await resolveOwnerContext(ctx); await Effect.runPromise(validateRepositoryName(args.repositoryName)); const account = await ctx.runQuery(findProviderAccountRef, { providerAccountId: args.providerAccountId, }); if (!account || account.organizationId !== organizationId) { throw new ConvexError("Provider account not found"); } await Effect.runPromise( assertOwnerSafeProvisioningInput( { providerAccountId: account.externalAccountId }, { externalAccountId: account.externalAccountId, externalEmail: account.externalEmail, externalUsername: account.externalUsername, provider: account.provider, serverUrl: account.serverUrl, } ) ); // Bind the owner to the authenticated account or a persisted org mapping. // The caller-supplied owner must match either the provider account's // external username (personal repo) or a gitProviderOrganizations record // owned by the same provider account (org repo). Arbitrary Puter users // or orgs are rejected. const isOrgOwner = args.owner !== account.externalUsername; let validatedOrgId: string | undefined; if (isOrgOwner) { const orgMapping = await ctx.runQuery(findProviderOrgByAccountRef, { externalSlug: args.owner, gitProviderAccountId: args.providerAccountId, }); if (!orgMapping) { throw new ConvexError( "Repository owner is not the authenticated Puter account or a mapped organization" ); } validatedOrgId = orgMapping.externalSlug; } const validatedOwner = isOrgOwner ? validatedOrgId! : account.externalUsername; const createEndpoint = isOrgOwner ? `${GITEA_API}/orgs/${encodeURIComponent(validatedOwner)}/repos` : `${GITEA_API}/admin/users/${encodeURIComponent(validatedOwner)}/repos`; const response = await fetch(createEndpoint, { body: JSON.stringify({ auto_init: args.autoInit, default_branch: args.defaultBranch, description: args.description, name: args.repositoryName, private: args.privacy === "private", }), headers: giteaAdminHeaders(), method: "POST", }); let repoData: { readonly clone_url: string; readonly id: number; readonly full_name: string; readonly html_url: string; readonly name: string; readonly owner: { readonly login: string }; }; let alreadyExisted = false; if (response.status === 409) { const existing = await fetch( `${GITEA_API}/repos/${encodeURIComponent(validatedOwner)}/${encodeURIComponent(args.repositoryName)}`, { headers: giteaAdminHeaders() } ); if (existing.ok) { repoData = (await existing.json()) as typeof repoData; alreadyExisted = true; } else { throw new ConvexError( `Gitea repo creation conflict (${response.status})` ); } } else if (response.ok) { repoData = (await response.json()) as typeof repoData; } else { throw new ConvexError(`Gitea repo creation failed (${response.status})`); } const repositoryId = await ctx.runMutation(upsertRepositoryRef, { cloneUrl: repoData.clone_url, defaultBranch: args.defaultBranch, fullName: repoData.full_name, gitProviderAccountId: args.providerAccountId, lfsCapability: "unknown", name: repoData.name, organizationId, owner: repoData.owner.login, permissionsAdmin: true, permissionsMaintain: true, permissionsPull: true, permissionsPush: true, permissionsTriage: true, privacy: args.privacy, provider: "gitea", providerRepositoryId: String(repoData.id), serverUrl: PUTER_GIT_SERVER_URL, webUrl: repoData.html_url, }); // Ensure a webhook is configured for the new repository. try { await ensurePuterWebhook(repoData.owner.login, repoData.name); await ctx.runMutation(updateWebhookStateRef, { repositoryId, webhookState: "active", }); } catch { await ctx.runMutation(updateWebhookStateRef, { repositoryId, webhookState: "failed", }); throw new ConvexError("Repository created but webhook setup failed"); } return { alreadyExisted, cloneUrl: repoData.clone_url, externalRepositoryId: String(repoData.id), fullName: repoData.full_name, name: repoData.name, owner: repoData.owner.login, repositoryId: String(repositoryId), webUrl: repoData.html_url, }; }, }); // --------------------------------------------------------------------------- // startGithubMigration // --------------------------------------------------------------------------- export const startGithubMigration = action({ args: { githubConnectionId: v.id("gitConnections"), includeLfs: v.boolean(), puterConnectionId: v.id("gitConnections"), sourceIsPrivate: v.boolean(), sourceRepositoryUrl: v.string(), targetOwner: v.string(), targetRepositoryName: v.string(), }, handler: async (ctx, args) => { const { organizationId } = await resolveOwnerContext(ctx); // Verify connections belong to the org const githubConn = await ctx.runQuery(getConnectionRef, { connectionId: args.githubConnectionId, }); const puterConn = await ctx.runQuery(getConnectionRef, { connectionId: args.puterConnectionId, }); if ( !githubConn || githubConn.organizationId !== organizationId || githubConn.provider !== "github" ) { throw new ConvexError("GitHub connection not found"); } if ( !puterConn || puterConn.organizationId !== organizationId || puterConn.provider !== "gitea" ) { throw new ConvexError("Puter connection not found"); } // Bind targetOwner to the Puter connection's provider account username // or a persisted org mapping belonging to that account. const puterAccount = await ctx.runQuery(findProviderAccountRef, { providerAccountId: puterConn.gitProviderAccountId!, }); if (!puterAccount || puterAccount.organizationId !== organizationId) { throw new ConvexError("Puter provider account not found"); } if (args.targetOwner !== puterAccount.externalUsername) { const orgMapping = await ctx.runQuery(findProviderOrgByAccountRef, { externalSlug: args.targetOwner, gitProviderAccountId: puterConn.gitProviderAccountId!, }); if (!orgMapping) { throw new ConvexError( "Migration target owner is not the authenticated Puter account or a mapped organization" ); } } const key = idempotencyKeyFor({ _tag: "MigrateGitHubRepository", githubConnectionId: String(args.githubConnectionId), includeLfs: args.includeLfs, organizationId: String(organizationId), puterConnectionId: String(args.puterConnectionId), sourceIsPrivate: args.sourceIsPrivate, sourceRepositoryUrl: args.sourceRepositoryUrl, targetOwner: args.targetOwner, targetRepositoryName: args.targetRepositoryName, }); // Check for existing migration (idempotency) const existing = await ctx.runQuery(findMigrationRef, { idempotencyKey: key, }); if (existing) { return { migrationId: String(existing._id), status: existing.status }; } const migrationId = await ctx.runMutation(createMigrationRef, { githubConnectionId: args.githubConnectionId, idempotencyKey: key, includeLfs: args.includeLfs, organizationId, puterConnectionId: args.puterConnectionId, sourceIsPrivate: args.sourceIsPrivate, sourceRepositoryUrl: args.sourceRepositoryUrl, targetOwner: args.targetOwner, targetRepositoryName: args.targetRepositoryName, }); // Schedule the migration as a durable action so it survives worker restarts. await ctx.scheduler.runAfter(0, runMigrationRef, { githubConnectionId: args.githubConnectionId, includeLfs: args.includeLfs, migrationId, puterConnectionId: args.puterConnectionId, sourceIsPrivate: args.sourceIsPrivate, sourceRepositoryUrl: args.sourceRepositoryUrl, targetOwner: args.targetOwner, targetRepositoryName: args.targetRepositoryName, }); return { migrationId: String(migrationId), status: "queued" }; }, }); const getMigrationInternalRef = makeFunctionReference< "query", { migrationId: Id<"gitMigrations"> }, Doc<"gitMigrations"> | null >("gitProvisioning:getMigrationInternal"); const runMigrationRef = makeFunctionReference< "action", { githubConnectionId: Id<"gitConnections">; includeLfs: boolean; migrationId: Id<"gitMigrations">; puterConnectionId: Id<"gitConnections">; sourceIsPrivate: boolean; sourceRepositoryUrl: string; targetOwner: string; targetRepositoryName: string; }, null >("gitProvisioning:runMigration"); export const runMigration = internalAction({ args: { githubConnectionId: v.id("gitConnections"), includeLfs: v.boolean(), migrationId: v.id("gitMigrations"), puterConnectionId: v.id("gitConnections"), sourceIsPrivate: v.boolean(), sourceRepositoryUrl: v.string(), targetOwner: v.string(), targetRepositoryName: v.string(), }, handler: async (ctx, args) => { await ctx.runMutation(updateMigrationStatusRef, { migrationId: args.migrationId, status: "running", }); try { const githubConn = await ctx.runQuery(getConnectionRef, { connectionId: args.githubConnectionId, }); const puterConn = await ctx.runQuery(getConnectionRef, { connectionId: args.puterConnectionId, }); if (!githubConn || !puterConn) { throw new ConvexError("Connection not found during migration"); } // Verify both connections belong to the migration's organization. const migration = await ctx.runQuery(getMigrationInternalRef, { migrationId: args.migrationId, }); if (!migration) { throw new ConvexError("Migration record not found"); } if ( githubConn.organizationId !== migration.organizationId || puterConn.organizationId !== migration.organizationId ) { throw new ConvexError( "Connection does not belong to this organization" ); } if (githubConn.provider !== "github" || puterConn.provider !== "gitea") { throw new ConvexError("Connection providers do not match migration"); } let githubToken = ""; if (args.sourceIsPrivate) { const { decryptCredential } = await import("./gitConnections"); githubToken = await decryptCredential( githubConn.credentialCiphertext, githubConn.credentialIv ); } const migrateResponse = await fetch(`${GITEA_API}/repos/migrate`, { body: JSON.stringify({ ...(githubToken ? { auth_token: githubToken } : {}), clone_addr: args.sourceRepositoryUrl, description: `Migrated from ${args.sourceRepositoryUrl}`, issues: false, labels: false, lfs: args.includeLfs, milestones: false, mirror: false, private: true, pull_requests: false, releases: false, repo_name: args.targetRepositoryName, repo_owner: args.targetOwner, service: "github", wiki: false, }), headers: giteaAdminHeaders(), method: "POST", }); if (!migrateResponse.ok) { const errorBody = await migrateResponse.text(); throw new ConvexError( `Gitea migration failed (${migrateResponse.status}): ${errorBody}` ); } const result = (await migrateResponse.json()) as { readonly clone_url: string; readonly default_branch: string; readonly full_name: string; readonly html_url: string; readonly id: number; readonly name: string; readonly owner: { readonly login: string }; readonly private: boolean; }; const repoId = await ctx.runMutation(upsertRepositoryRef, { cloneUrl: result.clone_url, defaultBranch: result.default_branch, fullName: result.full_name, gitProviderAccountId: (puterConn.gitProviderAccountId ?? "") as Id<"gitProviderAccounts">, lfsCapability: args.includeLfs ? "supported" : "unknown", name: result.name, organizationId: puterConn.organizationId, owner: result.owner.login, permissionsAdmin: true, permissionsMaintain: true, permissionsPull: true, permissionsPush: true, permissionsTriage: true, privacy: result.private ? "private" : "public", provider: "gitea", providerRepositoryId: String(result.id), serverUrl: PUTER_GIT_SERVER_URL, sourceMigrationId: args.migrationId, webUrl: result.html_url, }); // Ensure a webhook is configured for the migrated repository. try { await ensurePuterWebhook(result.owner.login, result.name); await ctx.runMutation(updateWebhookStateRef, { repositoryId: repoId, webhookState: "active", }); } catch { await ctx.runMutation(updateWebhookStateRef, { repositoryId: repoId, webhookState: "failed", }); throw new ConvexError("Migration succeeded but webhook setup failed"); } await ctx.runMutation(updateMigrationStatusRef, { migrationId: args.migrationId, resultingRepositoryId: repoId, status: "succeeded", }); } catch (error) { const failureReason = error instanceof Error ? error.message : "Migration failed"; await ctx.runMutation(updateMigrationStatusRef, { failureReason, migrationId: args.migrationId, status: "failed", }); throw error; } return null; }, }); // --------------------------------------------------------------------------- // getMigration // --------------------------------------------------------------------------- export const getMigrationInternal = internalQuery({ args: { migrationId: v.id("gitMigrations") }, handler: async (ctx, args) => await ctx.db.get(args.migrationId), }); export const getMigration = query({ args: { migrationId: v.id("gitMigrations") }, handler: async (ctx, args) => { const { organizationId } = await requireCurrentOrganization(ctx); const migration = await ctx.db.get(args.migrationId); if (!migration || migration.organizationId !== organizationId) { throw new ConvexError("Migration not found"); } return migration; }, }); // --------------------------------------------------------------------------- // Migration execution // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Internal mutations and queries // --------------------------------------------------------------------------- export const persistPuterUserAccount = internalMutation({ args: { betterAuthEmail: v.string(), betterAuthName: v.string(), externalUserId: v.string(), organizationId: v.id("organizations"), puterUsername: v.string(), userId: v.string(), }, handler: async (ctx, args) => { const existing = await ctx.db .query("gitProviderAccounts") .withIndex("by_userId_and_provider_and_serverUrl", (q) => q .eq("userId", args.userId) .eq("provider", "gitea") .eq("serverUrl", PUTER_GIT_SERVER_URL) ) .unique(); const timestamp = Date.now(); if (existing) { await ctx.db.patch(existing._id, { externalAccountId: args.externalUserId, externalEmail: args.betterAuthEmail, externalUsername: args.puterUsername, status: "pending-auth", updatedAt: timestamp, }); return existing._id; } return await ctx.db.insert("gitProviderAccounts", { createdAt: timestamp, externalAccountId: args.externalUserId, externalEmail: args.betterAuthEmail, externalUsername: args.puterUsername, organizationId: args.organizationId, provider: "gitea", serverUrl: PUTER_GIT_SERVER_URL, status: "pending-auth", updatedAt: timestamp, userId: args.userId, }); }, }); export const persistPuterOrg = internalMutation({ args: { displayName: v.optional(v.string()), externalOrgId: v.string(), gitProviderAccountId: v.id("gitProviderAccounts"), organizationId: v.id("organizations"), puterOrgName: v.string(), }, handler: async (ctx, args) => { const existing = await ctx.db .query("gitProviderOrganizations") .withIndex("by_gitProviderAccountId", (q) => q.eq("gitProviderAccountId", args.gitProviderAccountId) ) .filter((q) => q.eq(q.field("externalSlug"), args.puterOrgName)) .unique(); const timestamp = Date.now(); if (existing) { await ctx.db.patch(existing._id, { displayName: args.displayName, updatedAt: timestamp, verificationState: "verified", }); return existing._id; } return await ctx.db.insert("gitProviderOrganizations", { createdAt: timestamp, displayName: args.displayName, externalId: args.externalOrgId, externalSlug: args.puterOrgName, gitProviderAccountId: args.gitProviderAccountId, organizationId: args.organizationId, serverUrl: PUTER_GIT_SERVER_URL, updatedAt: timestamp, verificationState: "verified", visibility: "private", }); }, }); export const findProviderOrgByAccount = internalQuery({ args: { externalSlug: v.string(), gitProviderAccountId: v.id("gitProviderAccounts"), }, handler: async (ctx, args) => { const rows = await ctx.db .query("gitProviderOrganizations") .withIndex("by_gitProviderAccountId", (q) => q.eq("gitProviderAccountId", args.gitProviderAccountId) ) .collect(); const match = rows.find((r) => r.externalSlug === args.externalSlug); return match ? { _id: match._id, externalSlug: match.externalSlug } : null; }, }); export const findProviderAccount = internalQuery({ args: { providerAccountId: v.id("gitProviderAccounts") }, handler: async (ctx, args) => await ctx.db.get(args.providerAccountId), }); export const getConnection = internalQuery({ args: { connectionId: v.id("gitConnections") }, handler: async (ctx, args) => await ctx.db.get(args.connectionId), }); export const getRepository = internalQuery({ args: { repositoryId: v.id("gitRepositories") }, handler: async (ctx, args) => await ctx.db.get(args.repositoryId), }); export const findMigration = internalQuery({ args: { idempotencyKey: v.string() }, handler: async (ctx, args) => await ctx.db .query("gitMigrations") .withIndex("by_idempotencyKey", (q) => q.eq("idempotencyKey", args.idempotencyKey) ) .unique(), }); export const createMigration = internalMutation({ args: { githubConnectionId: v.id("gitConnections"), idempotencyKey: v.string(), includeLfs: v.boolean(), organizationId: v.id("organizations"), puterConnectionId: v.id("gitConnections"), sourceIsPrivate: v.boolean(), sourceRepositoryUrl: v.string(), targetOwner: v.string(), targetRepositoryName: v.string(), }, handler: async (ctx, args) => { const timestamp = Date.now(); return await ctx.db.insert("gitMigrations", { createdAt: timestamp, githubConnectionId: args.githubConnectionId, idempotencyKey: args.idempotencyKey, includeLfs: args.includeLfs, organizationId: args.organizationId, puterConnectionId: args.puterConnectionId, sourceIsPrivate: args.sourceIsPrivate, sourceRepositoryUrl: args.sourceRepositoryUrl, status: "queued", targetOwner: args.targetOwner, targetRepositoryName: args.targetRepositoryName, updatedAt: timestamp, }); }, }); export const updateMigrationStatus = internalMutation({ args: { failureReason: v.optional(v.string()), migrationId: v.id("gitMigrations"), resultingRepositoryId: v.optional(v.id("gitRepositories")), status: v.union( v.literal("queued"), v.literal("running"), v.literal("succeeded"), v.literal("failed"), v.literal("cancelled") ), }, handler: async (ctx, args) => { await ctx.db.patch(args.migrationId, { ...(args.failureReason ? { failureReason: args.failureReason } : {}), ...(args.resultingRepositoryId ? { resultingRepositoryId: args.resultingRepositoryId } : {}), status: args.status, updatedAt: Date.now(), }); return null; }, }); export const upsertRepository = internalMutation({ args: { cloneUrl: v.string(), defaultBranch: v.string(), fullName: v.string(), gitProviderAccountId: v.id("gitProviderAccounts"), lfsCapability: v.union( v.literal("unknown"), v.literal("supported"), v.literal("unsupported") ), name: v.string(), organizationId: v.id("organizations"), owner: v.string(), permissionsAdmin: v.boolean(), permissionsMaintain: v.boolean(), permissionsPull: v.boolean(), permissionsPush: v.boolean(), permissionsTriage: v.boolean(), privacy: v.union(v.literal("public"), v.literal("private")), provider: v.union(v.literal("github"), v.literal("gitea")), providerRepositoryId: v.string(), serverUrl: v.string(), sourceMigrationId: v.optional(v.id("gitMigrations")), webUrl: v.string(), }, handler: async (ctx, args) => { const existing = await ctx.db .query("gitRepositories") .withIndex("by_provider_and_serverUrl_and_externalRepositoryId", (q) => q .eq("provider", args.provider) .eq("serverUrl", args.serverUrl) .eq("providerRepositoryId", args.providerRepositoryId) ) .unique(); const timestamp = Date.now(); if (existing) { await ctx.db.patch(existing._id, { cloneUrl: args.cloneUrl, defaultBranch: args.defaultBranch, fullName: args.fullName, lfsCapability: args.lfsCapability, name: args.name, owner: args.owner, permissionsAdmin: args.permissionsAdmin, permissionsMaintain: args.permissionsMaintain, permissionsPull: args.permissionsPull, permissionsPush: args.permissionsPush, permissionsTriage: args.permissionsTriage, privacy: args.privacy, ...(args.sourceMigrationId ? { sourceMigrationId: args.sourceMigrationId } : {}), updatedAt: timestamp, webUrl: args.webUrl, }); return existing._id; } return await ctx.db.insert("gitRepositories", { cloneUrl: args.cloneUrl, createdAt: timestamp, defaultBranch: args.defaultBranch, fullName: args.fullName, gitProviderAccountId: args.gitProviderAccountId, lfsCapability: args.lfsCapability, name: args.name, organizationId: args.organizationId, owner: args.owner, permissionsAdmin: args.permissionsAdmin, permissionsMaintain: args.permissionsMaintain, permissionsPull: args.permissionsPull, permissionsPush: args.permissionsPush, permissionsTriage: args.permissionsTriage, privacy: args.privacy, provider: args.provider, providerRepositoryId: args.providerRepositoryId, serverUrl: args.serverUrl, ...(args.sourceMigrationId ? { sourceMigrationId: args.sourceMigrationId } : {}), updatedAt: timestamp, webUrl: args.webUrl, webhookState: "none", }); }, }); // --------------------------------------------------------------------------- // Project instructions update // --------------------------------------------------------------------------- export const updateProjectInstructions = internalMutation({ args: { instructions: v.string(), projectId: v.id("projects"), }, handler: async (ctx, args) => { await Effect.runPromise(validateProjectInstructions(args.instructions)); await ctx.db.patch(args.projectId, { instructions: args.instructions, updatedAt: Date.now(), }); }, }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const resolveOwnerContext = async ( ctx: unknown ): Promise<{ organizationId: Id<"organizations">; userId: string; }> => { const identity = await ( ctx as { auth: { getUserIdentity: () => Promise<{ readonly tokenIdentifier: string; } | null>; }; } ).auth.getUserIdentity(); if (!identity) { throw new ConvexError("Authentication required"); } const org = await ( ctx as { runQuery: ( ref: unknown, args: Record ) => Promise<{ organizationId: Id<"organizations">; userId: string }>; } ).runQuery(currentOrgForOwnerRef, {}); return org; }; export const recordWebhookDelivery = internalMutation({ args: { deliveryId: v.string(), event: v.string(), externalRepositoryId: v.optional(v.string()), payloadHash: v.string(), provider: v.union(v.literal("github"), v.literal("gitea")), }, handler: async (ctx, args) => { // Check for duplicate delivery (idempotency) const existing = await ctx.db .query("gitWebhookDeliveries") .withIndex("by_provider_and_deliveryId", (q) => q.eq("provider", args.provider).eq("deliveryId", args.deliveryId) ) .unique(); if (existing) { return { deliveryId: existing._id, duplicate: true }; } const timestamp = Date.now(); const deliveryId = await ctx.db.insert("gitWebhookDeliveries", { createdAt: timestamp, deliveryId: args.deliveryId, event: args.event, externalRepositoryId: args.externalRepositoryId, payloadHash: args.payloadHash, processingState: "received", provider: args.provider, updatedAt: timestamp, }); return { deliveryId, duplicate: false }; }, }); export const resolveRepositoryByExternalId = internalQuery({ args: { externalRepositoryId: v.string(), provider: v.union(v.literal("github"), v.literal("gitea")), }, handler: async (ctx, args) => { const rows = await ctx.db .query("gitRepositories") .withIndex("by_provider_and_serverUrl_and_externalRepositoryId", (q) => q.eq("provider", args.provider) ) .collect(); return ( rows.find((r) => r.providerRepositoryId === args.externalRepositoryId) ?? null ); }, }); export const updateWebhookState = internalMutation({ args: { repositoryId: v.id("gitRepositories"), webhookState: v.union( v.literal("none"), v.literal("pending"), v.literal("active"), v.literal("failed") ), }, handler: async (ctx, args) => { await ctx.db.patch(args.repositoryId, { updatedAt: Date.now(), webhookState: args.webhookState, }); return null; }, }); export const markDeliveryProcessed = internalMutation({ args: { deliveryId: v.id("gitWebhookDeliveries"), repositoryRef: v.optional(v.id("gitRepositories")), }, handler: async (ctx, args) => { await ctx.db.patch(args.deliveryId, { ...(args.repositoryRef ? { repositoryRef: args.repositoryRef } : {}), processingState: "processed", updatedAt: Date.now(), }); return null; }, }); const recordWebhookDeliveryRef = makeFunctionReference< "mutation", { deliveryId: string; event: string; externalRepositoryId?: string; payloadHash: string; provider: "github" | "gitea"; }, { deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean } >("gitProvisioning:recordWebhookDelivery"); export const getRecordWebhookDeliveryRef = () => recordWebhookDeliveryRef; export const resolveCurrentOrgForOwner = internalQuery({ args: {}, handler: async (ctx) => { const { organizationId } = await requireCurrentOrganization(ctx); const userId = await requireCurrentOrganizationOwner(ctx, organizationId); return { organizationId, userId }; }, }); export const listProviderAccounts = query({ args: {}, handler: async (ctx) => { const { organizationId } = await requireCurrentOrganization(ctx); const connections = await ctx.db .query("gitConnections") .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId) ) .collect(); const results = await Promise.all( connections.map(async (connection) => { const providerAccount = connection.gitProviderAccountId ? await ctx.db.get(connection.gitProviderAccountId) : null; const externalUsername = providerAccount?.externalUsername ?? connection.username ?? "connected"; return { externalUsername, grantedScopesJson: connection.grantedScopesJson, id: String(connection._id), provider: connection.provider, serverUrl: connection.serverUrl, status: connection.state ?? "active", }; }) ); return results; }, }); export const listRepositories = query({ args: {}, handler: async (ctx) => { const { organizationId } = await requireCurrentOrganization(ctx); const repos = await ctx.db .query("gitRepositories") .withIndex("by_organizationId", (q) => q.eq("organizationId", organizationId) ) .collect(); return repos.map((repo) => ({ cloneUrl: repo.cloneUrl, defaultBranch: repo.defaultBranch, fullName: repo.fullName, id: String(repo._id), name: repo.name, owner: repo.owner, privacy: repo.privacy, provider: repo.provider, webUrl: repo.webUrl, })); }, }); export const createProjectFromRepository = mutation({ args: { gitRepositoryId: v.id("gitRepositories"), name: v.string(), }, handler: async (ctx, args) => { const { organizationId } = await requireCurrentOrganization(ctx); const repo = await ctx.db.get(args.gitRepositoryId); if (!repo || repo.organizationId !== organizationId) { throw new ConvexError("Repository not found"); } // Find the active connection for this repository's provider account. // The connection must exist and be active for the Project to be executable. const connection = await ctx.db .query("gitConnections") .withIndex("by_gitProviderAccountId", (q) => q.eq("gitProviderAccountId", repo.gitProviderAccountId) ) .unique(); if (!connection) { throw new ConvexError( "No Git connection found for this repository's provider account" ); } if (connection.state !== undefined && connection.state !== "active") { throw new ConvexError( `Git connection is ${connection.state}; reconnect credentials before creating a Project` ); } const now = Date.now(); if ( connection.lastVerifiedAt === undefined || now - connection.lastVerifiedAt > CREDENTIAL_FRESHNESS_MS ) { throw new ConvexError( "Git credentials have not been verified recently; run a connection health check before creating a Project" ); } const timestamp = now; const projectId = await ctx.db.insert("projects", { createdAt: timestamp, defaultBranch: repo.defaultBranch, gitConnectionId: connection?._id, gitRepositoryId: args.gitRepositoryId, name: args.name, normalizedSourceUrl: repo.webUrl, organizationId, repositoryPath: repo.fullName, sourceHost: new URL(repo.serverUrl).hostname, sourceUrl: repo.webUrl, updatedAt: timestamp, }); return { projectId }; }, }); export const attachRepositoryToProject = internalMutation({ args: { gitRepositoryId: v.id("gitRepositories"), projectId: v.id("projects"), }, handler: async (ctx, args) => { const project = await ctx.db.get(args.projectId); if (!project) { throw new ConvexError("Project not found"); } const repo = await ctx.db.get(args.gitRepositoryId); if (!repo || repo.organizationId !== project.organizationId) { throw new ConvexError("Repository not found"); } await ctx.db.patch(args.projectId, { defaultBranch: repo.defaultBranch, gitRepositoryId: args.gitRepositoryId, normalizedSourceUrl: repo.webUrl, repositoryPath: repo.fullName, sourceHost: new URL(repo.serverUrl).hostname, sourceUrl: repo.webUrl, updatedAt: Date.now(), }); return { attached: true }; }, }); 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( v.object({ cloneUrl: v.string(), defaultBranch: v.string(), fullName: v.string(), name: v.string(), owner: v.string(), private: v.boolean(), providerRepositoryId: v.string(), serverUrl: v.string(), webUrl: v.string(), }) ), serverUrl: v.string(), }, handler: async (ctx, args) => { // `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 .query("gitRepositories") .withIndex("by_provider_and_serverUrl_and_externalRepositoryId", (q) => q .eq("provider", args.provider) .eq("serverUrl", repo.serverUrl) .eq("providerRepositoryId", repo.providerRepositoryId) ) .unique(); const timestamp = Date.now(); const fields = { cloneUrl: repo.cloneUrl, defaultBranch: repo.defaultBranch, fullName: repo.fullName, gitProviderAccountId: args.providerAccountId, lfsCapability: "unknown" as const, name: repo.name, owner: repo.owner, permissionsAdmin: true, permissionsMaintain: true, permissionsPull: true, permissionsPush: true, permissionsTriage: true, privacy: repo.private ? ("private" as const) : ("public" as const), provider: args.provider, updatedAt: timestamp, webUrl: repo.webUrl, }; await (existing ? ctx.db.patch(existing._id, fields) : ctx.db.insert("gitRepositories", { ...fields, createdAt: timestamp, organizationId, providerRepositoryId: repo.providerRepositoryId, serverUrl: repo.serverUrl, webhookState: "none", })); upserted += 1; } return upserted; }, });