import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all, first } from "~/server/db"; import { accountDb, repoDb } from "~/server/scoped-db"; import { randomId } from "~/server/crypto"; import { assertNotArchived, requireRepoAccess } from "~/server/repos"; import { accountsById } from "~/server/people"; import { assertBelowCap, CAPS, limitWrite } from "~/server/limits"; import { badRequest, notFoundError } from "~/server/errors"; import { deleteConfigValue, listSecretNames, listVariables, ownerRepoIds, requireOwnerAdmin, upsertSecret, upsertVariable, } from "~/server/ci/config-admin"; import type { ConfigScope } from "~/server/ci/config-admin"; import type { EnvironmentRow } from "~/server/ci/deployments"; import { repoRef, secretName } from "~/lib/validation"; const MAX_VALUE_BYTES = 48 * 1024; const MAX_WAIT_MINUTES = 43_200; const MAX_REVIEWERS = 6; const MAX_BRANCH_PATTERNS = 20; const configValue = z.object({ kind: z.enum(["secret", "variable"]), name: secretName, value: z.string().min(1).max(MAX_VALUE_BYTES), }); const environmentName = z .string() .trim() .regex(/^[A-Za-z0-9 ._-]{1,100}$/, "Use letters, numbers, spaces, '.', '-' and '_'"); function stringList(raw: string): string[] { const value: unknown = JSON.parse(raw); return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } async function setValue(scope: ConfigScope, kind: "secret" | "variable", name: string, value: string): Promise { if (kind === "secret") await upsertSecret(scope, name.toUpperCase(), value); else await upsertVariable(scope, name.toUpperCase(), value); } async function environmentFor(repoId: string, name: string): Promise { const environment = await repoDb(repoId).first( "SELECT * FROM environments WHERE repo_id = ? AND name = ?", repoId, name, ); if (!environment) throw notFoundError("Environment not found"); return environment; } export const getRepoActionsConfig = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); const environments = await repoDb(repo.id).all( "SELECT * FROM environments WHERE repo_id = ? ORDER BY name", repo.id, ); const reviewerIds = environments.flatMap((environment) => stringList(environment.reviewers)); const people = await accountsById(reviewerIds); return { variables: await listVariables({ kind: "repo", id: repo.id }), environments: await Promise.all( environments.map(async (environment) => ({ name: environment.name, waitTimerMinutes: environment.wait_timer_minutes, reviewers: stringList(environment.reviewers).map((id) => people.get(id)?.name ?? "ghost"), branchPatterns: stringList(environment.branch_patterns), preventSelfReview: environment.prevent_self_review === 1, secrets: await listSecretNames({ kind: "environment", id: environment.id, repoId: environment.repo_id }), variables: await listVariables({ kind: "environment", id: environment.id, repoId: environment.repo_id }), })), ), }; }); export const setRepoVariable = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ name: secretName, value: z.string().min(1).max(MAX_VALUE_BYTES) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); assertNotArchived(repo); await setValue({ kind: "repo", id: repo.id }, "variable", data.name, data.value); return { ok: true }; }); export const deleteRepoVariable = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ name: z.string().min(1) })) .handler(async ({ data, context }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); await deleteConfigValue("variable", { kind: "repo", id: repo.id }, data.name); return { ok: true }; }); export const saveEnvironment = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ name: environmentName, waitTimerMinutes: z.number().int().min(0).max(MAX_WAIT_MINUTES), reviewers: z.array(z.string().trim().min(1)).max(MAX_REVIEWERS), branchPatterns: z.array(z.string().trim().min(1).max(200)).max(MAX_BRANCH_PATTERNS), preventSelfReview: z.boolean(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); assertNotArchived(repo); const reviewerIds: string[] = []; for (const login of data.reviewers) { const account = await first<{ id: string }>("SELECT id FROM accounts WHERE name = ? COLLATE NOCASE", login); if (!account) throw badRequest(`Unknown reviewer '${login}'`); reviewerIds.push(account.id); } const existing = await repoDb(repo.id).first( "SELECT 1 AS ok FROM environments WHERE repo_id = ? AND name = ?", repo.id, data.name, ); if (!existing) { const count = await repoDb(repo.id).first<{ count: number }>( "SELECT COUNT(*) AS count FROM environments WHERE repo_id = ?", repo.id, ); assertBelowCap(count?.count ?? 0, CAPS.environmentsPerRepo, "environments"); } await repoDb(repo.id).run( `INSERT INTO environments (id, repo_id, name, wait_timer_minutes, reviewers, branch_patterns, prevent_self_review, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (repo_id, name) DO UPDATE SET wait_timer_minutes = excluded.wait_timer_minutes, reviewers = excluded.reviewers, branch_patterns = excluded.branch_patterns, prevent_self_review = excluded.prevent_self_review`, randomId(), repo.id, data.name, data.waitTimerMinutes, JSON.stringify([...new Set(reviewerIds)]), JSON.stringify(data.branchPatterns), data.preventSelfReview ? 1 : 0, Date.now(), ); return { ok: true }; }); export const deleteEnvironment = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ name: z.string().min(1) })) .handler(async ({ data, context }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); await repoDb(repo.id).run("DELETE FROM environments WHERE repo_id = ? AND name = ?", repo.id, data.name); return { ok: true }; }); export const setEnvironmentValue = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ environment: z.string().min(1) }).merge(configValue)) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); assertNotArchived(repo); const environment = await environmentFor(repo.id, data.environment); await setValue( { kind: "environment", id: environment.id, repoId: environment.repo_id }, data.kind, data.name, data.value, ); return { ok: true }; }); export const deleteEnvironmentValue = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ environment: z.string().min(1), kind: z.enum(["secret", "variable"]), name: z.string() })) .handler(async ({ data, context }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); const environment = await environmentFor(repo.id, data.environment); await deleteConfigValue( data.kind, { kind: "environment", id: environment.id, repoId: environment.repo_id }, data.name, ); return { ok: true }; }); export const listActionsOwners = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .handler(async ({ context }) => { const viewer = requireViewer(context.viewer); const orgs = await all<{ name: string }>( `SELECT a.name FROM org_members m JOIN accounts a ON a.id = m.org_id WHERE m.user_id = ? AND m.role = 'owner' ORDER BY a.name`, viewer.id, ); return [viewer.name, ...orgs.map((org) => org.name)]; }); export const getOwnerActionsConfig = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(z.object({ owner: z.string().min(1) })) .handler(async ({ data, context }) => { const account = await requireOwnerAdmin(data.owner, context.viewer); const repos = await all<{ id: string; name: string }>( "SELECT id, name FROM repos WHERE owner_id = ? ORDER BY name LIMIT 500", account.id, ); const names = new Map(repos.map((repo) => [repo.id, repo.name])); const describe = (row: { visibility: string; selected_repos: string }) => ({ visibility: row.visibility, repos: stringList(row.selected_repos).flatMap((id) => names.get(id) ?? []), }); const [secrets, variables] = await Promise.all([ accountDb(account.id).all<{ name: string; visibility: string; selected_repos: string; updated_at: number }>( "SELECT name, visibility, selected_repos, updated_at FROM owner_secrets WHERE owner_id = ? ORDER BY name", account.id, ), accountDb(account.id).all<{ name: string; value: string; visibility: string; selected_repos: string; updated_at: number; }>( "SELECT name, value, visibility, selected_repos, updated_at FROM owner_variables WHERE owner_id = ? ORDER BY name", account.id, ), ]); return { owner: account.name, repos: repos.map((repo) => repo.name), secrets: secrets.map((row) => ({ name: row.name, updatedAt: row.updated_at, ...describe(row) })), variables: variables.map((row) => ({ name: row.name, value: row.value, updatedAt: row.updated_at, ...describe(row), })), }; }); export const setOwnerValue = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( configValue.extend({ owner: z.string().min(1), visibility: z.enum(["all", "private", "selected"]), repos: z.array(z.string()).max(90).default([]), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const account = await requireOwnerAdmin(data.owner, viewer); const selectedRepos = data.visibility === "selected" ? await ownerRepoIds(account.id, data.repos) : []; await setValue( { kind: "owner", id: account.id, visibility: data.visibility, selectedRepos }, data.kind, data.name, data.value, ); return { ok: true }; }); export const deleteOwnerValue = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ owner: z.string().min(1), kind: z.enum(["secret", "variable"]), name: z.string().min(1) })) .handler(async ({ data, context }) => { await limitWrite(requireViewer(context.viewer)); const account = await requireOwnerAdmin(data.owner, context.viewer); await deleteConfigValue( data.kind, { kind: "owner", id: account.id, visibility: "all", selectedRepos: [] }, data.name, ); return { ok: true }; });