import { providerForHost } from "@code/primitives/git-provider"; import { ConvexError, v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { internalMutation, mutation, query } from "./_generated/server"; import type { MutationCtx } from "./_generated/server"; import { requireCurrentOrganization, requireProjectMember } from "./authz"; const upsertProviderAccount = async ( ctx: MutationCtx, args: { externalAccountId: string; externalEmail?: string; externalUsername: string; organizationId: Id<"organizations">; provider: "github" | "gitea"; serverUrl: string; userId: string; } ): Promise> => { const existing = await ctx.db .query("gitProviderAccounts") .withIndex("by_userId_and_provider_and_serverUrl", (q) => q .eq("userId", args.userId) .eq("provider", args.provider) .eq("serverUrl", args.serverUrl) ) .unique(); const timestamp = Date.now(); if (existing) { await ctx.db.patch(existing._id, { externalAccountId: args.externalAccountId, externalEmail: args.externalEmail, externalUsername: args.externalUsername, status: "active", updatedAt: timestamp, }); return existing._id; } return await ctx.db.insert("gitProviderAccounts", { createdAt: timestamp, externalAccountId: args.externalAccountId, externalEmail: args.externalEmail, externalUsername: args.externalUsername, organizationId: args.organizationId, provider: args.provider, serverUrl: args.serverUrl, status: "active", updatedAt: timestamp, userId: args.userId, }); }; export const persist = internalMutation({ args: { credentialCiphertext: v.string(), credentialIv: v.string(), credentialKind: v.union(v.literal("oauth"), v.literal("token")), externalAccountId: v.string(), externalEmail: v.optional(v.string()), externalUsername: v.string(), grantedScopesJson: v.optional(v.string()), provider: v.union(v.literal("github"), v.literal("gitea")), serverUrl: v.string(), userId: v.string(), username: v.optional(v.string()), }, handler: async (ctx, args) => { const organization = await ctx.db .query("organizations") .withIndex("by_createdBy_and_kind", (q) => q.eq("createdBy", args.userId).eq("kind", "personal") ) .unique(); if (!organization) { throw new ConvexError("Organization not found"); } const providerAccountId = await upsertProviderAccount(ctx, { externalAccountId: args.externalAccountId, externalEmail: args.externalEmail, externalUsername: args.externalUsername, organizationId: organization._id, provider: args.provider, serverUrl: args.serverUrl, userId: args.userId, }); const existing = await ctx.db .query("gitConnections") .withIndex("by_gitProviderAccountId", (q) => q.eq("gitProviderAccountId", providerAccountId) ) .unique(); const timestamp = Date.now(); if (existing) { await ctx.db.patch(existing._id, { credentialCiphertext: args.credentialCiphertext, credentialIv: args.credentialIv, credentialKind: args.credentialKind, grantedScopesJson: args.grantedScopesJson, lastError: undefined, lastVerifiedAt: timestamp, reauthRequiredAt: undefined, state: "active", updatedAt: timestamp, username: args.username, }); return existing._id; } return await ctx.db.insert("gitConnections", { connectedAt: timestamp, creatorUserId: args.userId, credentialCiphertext: args.credentialCiphertext, credentialIv: args.credentialIv, credentialKind: args.credentialKind, gitProviderAccountId: providerAccountId, grantedScopesJson: args.grantedScopesJson, lastVerifiedAt: timestamp, organizationId: organization._id, provider: args.provider, serverUrl: args.serverUrl, state: "active", updatedAt: timestamp, username: args.username, }); }, }); export const list = 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(); return connections.map((connection) => ({ connectedAt: connection.connectedAt, credentialKind: connection.credentialKind, grantedScopesJson: connection.grantedScopesJson, id: String(connection._id), lastVerifiedAt: connection.lastVerifiedAt, provider: connection.provider, serverUrl: connection.serverUrl, state: connection.state, username: connection.username, })); }, }); export const getForProject = query({ args: { projectId: v.id("projects") }, handler: async (ctx, args) => { await requireProjectMember(ctx, args.projectId); const project = await ctx.db.get(args.projectId); const connection = project?.gitConnectionId ? await ctx.db.get(project.gitConnectionId) : null; return connection ? { connectedAt: connection.connectedAt, credentialKind: connection.credentialKind, id: String(connection._id), lastVerifiedAt: connection.lastVerifiedAt, provider: connection.provider, serverUrl: connection.serverUrl, state: connection.state, username: connection.username, } : null; }, }); export const attachToProject = mutation({ args: { connectionId: v.id("gitConnections"), projectId: v.id("projects"), }, handler: async (ctx, args) => { const { organizationId } = await requireProjectMember(ctx, args.projectId); const connection = await ctx.db.get(args.connectionId); if (!connection || connection.organizationId !== organizationId) { throw new ConvexError("Git connection not found"); } const project = await ctx.db.get(args.projectId); if (project) { const expected = providerForHost(project.sourceHost); if (expected && connection.provider !== expected) { throw new ConvexError( `Git credential provider (${connection.provider}) does not match this project's provider (${expected})` ); } } await ctx.db.patch(args.projectId, { gitConnectionId: connection._id, updatedAt: Date.now(), }); return { attached: true }; }, }); /** * Backfill legacy gitConnections rows that predate the normalized schema. * Sets default values for creatorUserId, state, and lastVerifiedAt when * missing. Also creates a gitProviderAccount for each legacy connection. */ export const backfillConnections = internalMutation({ args: {}, handler: async (ctx) => { const all = await ctx.db.query("gitConnections").collect(); let migrated = 0; for (const conn of all) { if (conn.state !== undefined && conn.gitProviderAccountId !== undefined) { continue; } const timestamp = Date.now(); // Create a provider account for the legacy connection. const providerAccountId = await ctx.db.insert("gitProviderAccounts", { createdAt: conn.connectedAt, externalAccountId: conn.username ?? "legacy", externalUsername: conn.username ?? "legacy", organizationId: conn.organizationId, provider: conn.provider, serverUrl: conn.serverUrl, status: "reauth-required", updatedAt: timestamp, userId: conn.creatorUserId ?? "legacy", }); // Mark as reauth-required so the health check cron verifies the // credential before marking active. This prevents trusting legacy // credentials without verification. await ctx.db.patch(conn._id, { creatorUserId: conn.creatorUserId ?? "legacy", gitProviderAccountId: providerAccountId, reauthRequiredAt: timestamp, state: "reauth-required", updatedAt: timestamp, }); migrated += 1; } return { migrated }; }, });