import { ConvexError, v } from "convex/values"; import { internalMutation, mutation, query } from "./_generated/server"; import { requireCurrentOrganization, requireProjectMember } from "./authz"; // Provider ("forge") that owns a repository host. A project's source must be // served by the same forge whose credentials are attached, so a Gitea token is // never offered to GitHub and vice versa. Unknown hosts return null, leaving // the caller free to attach without a forge constraint. export const forgeForHost = (host: string): "github" | "gitea" | null => { const normalized = host.toLowerCase(); if (normalized === "github.com" || normalized.endsWith(".githost.com")) { return "github"; } if (normalized === "git.openputer.com") { return "gitea"; } return null; }; export const persist = internalMutation({ args: { credentialCiphertext: v.string(), credentialIv: v.string(), credentialKind: v.union(v.literal("oauth"), v.literal("token")), 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 existing = await ctx.db .query("gitConnections") .withIndex("by_organizationId_and_provider_and_serverUrl", (q) => q .eq("organizationId", organization._id) .eq("provider", args.provider) .eq("serverUrl", args.serverUrl) ) .unique(); const timestamp = Date.now(); if (existing) { await ctx.db.patch(existing._id, { credentialCiphertext: args.credentialCiphertext, credentialIv: args.credentialIv, credentialKind: args.credentialKind, updatedAt: timestamp, username: args.username, }); return existing._id; } return await ctx.db.insert("gitConnections", { connectedAt: timestamp, credentialCiphertext: args.credentialCiphertext, credentialIv: args.credentialIv, credentialKind: args.credentialKind, organizationId: organization._id, provider: args.provider, serverUrl: args.serverUrl, 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, id: String(connection._id), provider: connection.provider, serverUrl: connection.serverUrl, 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), provider: connection.provider, serverUrl: connection.serverUrl, 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 = forgeForHost(project.sourceHost); if (expected && connection.provider !== expected) { throw new ConvexError( `Git credential provider (${connection.provider}) does not match this project's forge (${expected})` ); } } await ctx.db.patch(args.projectId, { gitConnectionId: connection._id, updatedAt: Date.now(), }); return { attached: true }; }, });