import { createServerFn } from "@tanstack/react-start"; import { assertBelowCap, CAPS, limitWrite } from "~/server/limits"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all, first, run } from "~/server/db"; import { randomId } from "~/server/crypto"; import { encryptRepoSecret } from "~/server/secrets"; import { requireRepoAccess } from "~/server/repos"; import { deliverWebhook, isForgeWebhookUrl } from "~/server/events/consumer"; import { WEBHOOK_EVENTS } from "~/lib/webhook-events"; import type { WebhookEvent } from "~/lib/webhook-events"; import { listRepoRunners, mintRegistrationToken, removeRepoRunner, repoScope } from "~/server/ci/runner-registration"; import { forkRunsAllowed, setForkRunsAllowed } from "~/server/ci/runner-policy"; import { notFoundError } from "~/server/errors"; import { repoRef, secretName } from "~/lib/validation"; import { deleteProtectionRule, listProtectionRules, saveProtectionRule } from "~/server/protection-write"; export const listProtections = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); return listProtectionRules(repo.id); }); export const saveProtection = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ id: z.string().nullable(), pattern: z.string().trim().min(1).max(200), requirePullRequest: z.boolean(), requiredApprovals: z.number().int().min(0).max(10), requiredChecks: z.array(z.string().min(1)).max(50), dismissStaleReviews: z.boolean(), allowDeletions: z.boolean(), allowForcePushes: z.boolean().default(false), enforceAdmins: z.boolean(), }), ) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); await saveProtectionRule(repo.id, data.id, data); return { ok: true }; }); export const deleteProtection = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string() })) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); await deleteProtectionRule(repo.id, data.id); return { ok: true }; }); type WebhookRow = { id: string; url: string; secret: string | null; events: string; active: number; created_at: number; }; export const listWebhooks = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); const hooks = await all("SELECT * FROM webhooks WHERE repo_id = ? ORDER BY created_at", repo.id); const deliveries = await all<{ id: string; webhook_id: string; event: string; status_code: number | null; error: string | null; duration_ms: number | null; attempt: number; created_at: number; }>( `SELECT d.id, d.webhook_id, d.event, d.status_code, d.error, d.duration_ms, d.attempt, d.created_at FROM webhook_deliveries d JOIN webhooks w ON w.id = d.webhook_id WHERE w.repo_id = ? ORDER BY d.created_at DESC LIMIT 100`, repo.id, ); return { availableEvents: WEBHOOK_EVENTS, hooks: hooks.map((hook) => ({ id: hook.id, url: hook.url, hasSecret: Boolean(hook.secret), events: JSON.parse(hook.events) as string[], active: hook.active === 1, deliveries: deliveries.filter((delivery) => delivery.webhook_id === hook.id).slice(0, 20), })), }; }); const webhookEvent = z.custom( (value) => value === "*" || WEBHOOK_EVENTS.some((event) => event === value), ); export const saveWebhook = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ id: z.string().nullable(), url: z .url() .refine((value) => /^https?:\/\//.test(value), "Webhook URLs must be http(s)") .refine((value) => !isForgeWebhookUrl(value), "Webhook URLs may not point at this forge"), secret: z.string().max(200).nullable(), events: z.array(webhookEvent).min(1), active: z.boolean(), }), ) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); if (!data.id) assertBelowCap( (await first<{ count: number }>("SELECT COUNT(*) AS count FROM webhooks WHERE repo_id = ?", repo.id))?.count ?? 0, CAPS.webhooksPerRepo, "webhooks", ); if (data.id) { await run( "UPDATE webhooks SET url = ?, secret = COALESCE(?, secret), events = ?, active = ? WHERE id = ? AND repo_id = ?", data.url, data.secret, JSON.stringify(data.events), data.active ? 1 : 0, data.id, repo.id, ); return { id: data.id }; } const id = randomId(); await run( "INSERT INTO webhooks (id, repo_id, url, secret, events, active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", id, repo.id, data.url, data.secret, JSON.stringify(data.events), data.active ? 1 : 0, Date.now(), ); await deliverWebhook( { id, url: data.url, secret: data.secret }, "ping", randomId(), { zen: "Keep it logically awesome.", hook_id: id }, 1, ); return { id }; }); export const deleteWebhook = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string() })) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); await run("DELETE FROM webhooks WHERE id = ? AND repo_id = ?", data.id, repo.id); return { ok: true }; }); export const redeliverWebhook = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ deliveryId: z.string() })) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); const delivery = await first<{ webhook_id: string; event: WebhookEvent; request_body: string; url: string; secret: string | null; }>( `SELECT d.webhook_id, d.event, d.request_body, w.url, w.secret FROM webhook_deliveries d JOIN webhooks w ON w.id = d.webhook_id WHERE d.id = ? AND w.repo_id = ?`, data.deliveryId, repo.id, ); if (!delivery) throw notFoundError("Delivery not found"); const ok = await deliverWebhook( { id: delivery.webhook_id, url: delivery.url, secret: delivery.secret }, delivery.event, randomId(), JSON.parse(delivery.request_body), 1, ); return { ok }; }); export const listSecrets = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); return all<{ name: string; updated_at: number }>( "SELECT name, updated_at FROM secrets WHERE repo_id = ? ORDER BY name", repo.id, ); }); export const setSecret = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ name: secretName, value: z .string() .min(1) .max(48 * 1024), }), ) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); const name = data.name.toUpperCase(); if (!(await first("SELECT 1 AS ok FROM secrets WHERE repo_id = ? AND name = ?", repo.id, name))) { const count = await first<{ count: number }>("SELECT COUNT(*) AS count FROM secrets WHERE repo_id = ?", repo.id); assertBelowCap(count?.count ?? 0, CAPS.secretsPerRepo, "secrets"); } const encrypted = await encryptRepoSecret(data.value); await run( `INSERT INTO secrets (repo_id, name, ciphertext, iv, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (repo_id, name) DO UPDATE SET ciphertext = excluded.ciphertext, iv = excluded.iv, updated_at = excluded.updated_at`, repo.id, name, encrypted.ciphertext, encrypted.iv, Date.now(), ); return { ok: true }; }); export const deleteSecret = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ name: z.string() })) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); await run("DELETE FROM secrets WHERE repo_id = ? AND name = ?", repo.id, data.name); return { ok: true }; }); export const listRunners = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); return { runners: await listRepoRunners(repo), allowForkPullRequests: await forkRunsAllowed(repo.id), public: repo.visibility === "public", }; }); export const createRunnerRegistration = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ labels: z.array(z.string().trim().min(1).max(32)).max(20), scope: z.enum(["repository", "owner"]), isolation: z.enum(["container", "none"]), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); await limitWrite(viewer); const scope = await repoScope(repo, data.scope, viewer); return mintRegistrationToken(scope, viewer.id, data.labels, data.isolation); }); export const setRunnerForkPolicy = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ allow: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); await limitWrite(viewer); await setForkRunsAllowed(repo.id, data.allow, viewer.id); return { ok: true }; }); export const deleteRunner = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); await limitWrite(viewer); if (!(await removeRepoRunner(repo, data.id, viewer))) throw notFoundError("Runner not found"); return { ok: true }; });