70 lines · 3.1 KB
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { requireViewer, viewerMiddleware } from "./middleware";
import type { Viewer } from "~/lib/types";
import { forbidden } from "~/server/errors";
import { limitWrite } from "~/server/limits";
import { billingAccountFor } from "~/server/metering/access";
import { canAdministerAccount, requireRepoAccess } from "~/server/repos";
import { AI_ENDPOINT_KINDS, deleteEndpoint, saveEndpoint } from "~/server/ai/endpoint";
import type { AiScope } from "~/server/ai/endpoint";

/** Where an endpoint is saved: a repository (repo admins) or an account (the user, org owners, site admins). */
const target = z.discriminatedUnion("scope", [
  z.object({ scope: z.literal("repo"), owner: z.string().min(1).max(100), repo: z.string().min(1).max(100) }),
  z.object({ scope: z.literal("account"), org: z.string().min(1).max(100).nullable() }),
]);

async function resolveTarget(
  input: z.infer<typeof target>,
  viewer: Viewer,
): Promise<{ scope: AiScope; ownerId: string }> {
  if (input.scope === "repo") {
    const { repo } = await requireRepoAccess(input.owner, input.repo, viewer, "admin");
    // The endpoint spends the owner's money and receives repository content, so the owner decides, not any repo admin.
    if (!(await canAdministerAccount(repo.owner_id, viewer))) {
      throw forbidden("Only the repository owner can change its AI endpoint");
    }
    return { scope: "repo", ownerId: repo.id };
  }
  const account = await billingAccountFor(viewer, input.org);
  if (!account.canEdit || !(await canAdministerAccount(account.accountId, viewer))) {
    throw forbidden("Only the account owner can change its AI endpoint");
  }
  return { scope: "account", ownerId: account.accountId };
}

/** Saves an endpoint. The key is sealed and never returned: the response carries only the summary. */
export const saveAiEndpoint = createServerFn({ method: "POST" })
  .middleware([viewerMiddleware])
  .validator(