import { z } from "zod"; import { isReservedName } from "./reserved-names"; /** Account name shape only; `accountName` also refuses reserved names. */ export const accountNameFormat = z .string() .trim() .min(1) .max(39) .regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/, "Use letters, numbers and single hyphens"); export const accountName = accountNameFormat.refine((name) => !isReservedName(name), "That name is reserved"); export const repoName = z .string() .trim() .min(1) .max(100) .regex(/^[A-Za-z0-9._-]+$/, "Use letters, numbers, '.', '-' and '_'") .refine((name) => !name.endsWith(".git") && name !== "." && name !== "..", "Invalid repository name"); export const repoRef = z.object({ owner: z.string().min(1), repo: z.string().min(1) }); export const password = z.string().min(8, "Use at least 8 characters").max(200); const DISPLAY_NAME_FORBIDDEN = /[<>\u0000-\u001f\u007f]/g; /** * Display names end up in git author/committer idents and `Co-authored-by` trailers, where `<`, `>` and * newlines would forge a mailbox or start a new header line. Strip them and collapse whitespace; an empty * result becomes `null` so callers fall back to the login. */ export function cleanDisplayName(value: string | null | undefined): string | null { if (value === null || value === undefined) return null; const cleaned = value.replace(DISPLAY_NAME_FORBIDDEN, "").replace(/\s+/g, " ").trim(); return cleaned.length > 0 ? cleaned : null; } export const displayName = z.string().max(100).nullable().transform(cleanDisplayName); const MAX_WEBSITE_LENGTH = 255; /** Returns the normalized absolute `http(s)` URL, or `null` when the value is unusable as an `href`. */ export function safeWebsiteUrl(value: string | null | undefined): string | null { if (!value) return null; const trimmed = value.trim(); if (trimmed.length === 0 || trimmed.length > MAX_WEBSITE_LENGTH) return null; const candidate = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`; let url: URL; try { url = new URL(candidate); } catch { return null; } if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (url.username || url.password || !url.hostname) return null; return url.href.length <= MAX_WEBSITE_LENGTH ? url.href : null; } /** Profile/repository website: absolute `http(s)` only, normalized; empty becomes `null`. */ export const website = z .string() .max(MAX_WEBSITE_LENGTH) .nullable() .transform((value, ctx) => { if (value === null || value.trim().length === 0) return null; const url = safeWebsiteUrl(value); if (!url) ctx.addIssue({ code: "custom", message: "Website must be an http(s) URL" }); return url; }); export const email = z.email(); export const markdownBody = z.string().max(65536); export const title = z.string().trim().min(1, "Title is required").max(256); export const labelColor = z.string().regex(/^[0-9a-fA-F]{6}$/, "Use a 6-digit hex color"); export const secretName = z .string() .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "Secret names must be valid environment variable names") .max(100);