import { createServerFn } from "@tanstack/react-start"; import { webhookEventOf } from "~/lib/webhook-events"; import { findWebhookDelivery, listWebhookDeliveries } from "~/server/webhook-log"; import { assertBelowCap, CAPS, limitWrite } from "~/server/limits"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { repoDb } from "~/server/scoped-db"; import { forgetLocator, registerLocator } from "~/server/scoped-db/locators"; import { scopedId } from "~/server/scoped-db/ids"; import { randomId } from "~/server/crypto"; import { encryptRepoSecret } from "~/server/secrets"; import { assertNotArchived, canAdministerAccount, requireRepoAccess } from "~/server/repos"; import { env } from "cloudflare:workers"; import { isForgeWebhookUrl } from "~/server/events/consumer"; import { assertWebhookTarget, deliverWebhook, MAX_WEBHOOK_SECRET, MIN_WEBHOOK_SECRET, replayableBody, sealWebhookSecret, } from "~/server/webhooks/legacy"; 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 }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); assertNotArchived(repo); 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 }) => { await limitWrite(requireViewer(context.viewer)); 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 repoDb(repo.id).all( "SELECT * FROM webhooks WHERE repo_id = ? ORDER BY created_at", repo.id, ); const deliveries = await listWebhookDeliveries(repo.id, null, 100); 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), ); const webhookUrl = 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"); /** Empty string clears the secret (unsigned); null keeps the stored one on update. */ const webhookSecret = z .string() .max(MAX_WEBHOOK_SECRET) .refine((value) => value === "" || value.length >= MIN_WEBHOOK_SECRET, "Secrets need at least 8 characters") .nullable(); export const saveWebhook = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ id: z.string().nullable(), url: webhookUrl, secret: webhookSecret, events: z.array(webhookEvent).min(1), active: z.boolean(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); assertNotArchived(repo); await limitWrite(viewer); await assertWebhookTarget(data.url); const sealed = data.secret === null ? null : await sealWebhookSecret(data.secret === "" ? null : data.secret); if (!data.id) assertBelowCap( ( await repoDb(repo.id).first<{ count: number }>( "SELECT COUNT(*) AS count FROM webhooks WHERE repo_id = ?", repo.id, ) )?.count ?? 0, CAPS.webhooksPerRepo, "webhooks", ); if (data.id) { await repoDb(repo.id).run( data.secret === null ? "UPDATE webhooks SET url = ?, events = ?, active = ? WHERE id = ? AND repo_id = ?" : "UPDATE webhooks SET url = ?, events = ?, active = ?, secret = ? WHERE id = ? AND repo_id = ?", data.url, JSON.stringify(data.events), data.active ? 1 : 0, ...(data.secret === null ? [] : [sealed]), data.id, repo.id, ); return { id: data.id }; } const id = scopedId(repo.id); await repoDb(repo.id).run( "INSERT INTO webhooks (id, repo_id, url, secret, events, active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", id, repo.id, data.url, sealed, JSON.stringify(data.events), data.active ? 1 : 0, Date.now(), ); await registerLocator("webhook", id, repo.id); await env.EVENTS.send({ kind: "webhook.deliver", webhookId: id, event: "ping", deliveryId: randomId(), payload: { zen: "Keep it logically awesome.", hook_id: id }, }); return { id }; }); export const deleteWebhook = 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); const deleted = await repoDb(repo.id).run("DELETE FROM webhooks WHERE id = ? AND repo_id = ?", data.id, repo.id); if (deleted.meta.changes > 0) await forgetLocator("webhook", data.id); return { ok: true }; }); export const redeliverWebhook = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ deliveryId: z.string() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); assertNotArchived(repo); await limitWrite(viewer); const delivery = await findWebhookDelivery(repo.id, data.deliveryId, null); if (!delivery) throw notFoundError("Delivery not found"); const ok = await deliverWebhook( { id: delivery.webhook_id, repo_id: repo.id, url: delivery.url, secret: delivery.secret }, webhookEventOf(delivery.event), randomId(), replayableBody(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 repoDb(repo.id).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 }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); assertNotArchived(repo); const name = data.name.toUpperCase(); if (!(await repoDb(repo.id).first("SELECT 1 AS ok FROM secrets WHERE repo_id = ? AND name = ?", repo.id, name))) { const count = await repoDb(repo.id).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 repoDb(repo.id).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 }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); await repoDb(repo.id).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"); const runners = await listRepoRunners(repo); const ownerAdmin = await canAdministerAccount(repo.owner_id, context.viewer); return { runners: ownerAdmin ? runners : runners.filter((runner) => runner.scope === "repository"), 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"); assertNotArchived(repo); 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"); assertNotArchived(repo); 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 }; });