// Artifact rules shared by the server and the CLI. Keep this module free of // package dependencies because the installed CLI imports it directly. export const MAX_ARTIFACT_SIZE = 10 * 1024 * 1024; export const MAX_ARTIFACT_TITLE_LENGTH = 120; export const MAX_ARTIFACT_DESCRIPTION_LENGTH = 280; export interface Artifact { readonly id: string; readonly title: string; readonly description?: string; readonly fileName: string; readonly projectId?: string; readonly size: number; readonly createdAt: string; readonly updatedAt: string; readonly openUrl: string; /** * The artifact HTML itself. Previews are the browser rendering this URL in a * sandboxed frame, so there is nothing to generate, cache, or invalidate. */ readonly contentUrl: string; } export interface NewArtifact { readonly title: string; readonly description?: string; readonly projectId?: string; readonly fileName: string; readonly content: string; } export interface ArtifactUpdate { readonly title?: string; readonly description?: string | null; readonly projectId?: string | null; readonly fileName?: string; readonly content?: string; } export type ArtifactListQuery = | { readonly scope: "all" } | { readonly scope: "global" } | { readonly scope: "project"; readonly projectId: string }; export const isValidArtifactId = (id: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id); // Reject anything that is not a bare .html filename. This is the only gate // between a source filename and artifact file storage. export const isValidArtifactName = (name: string) => /^[A-Za-z0-9][A-Za-z0-9._-]*\.html$/.test(name) && !name.includes(".."); export const artifactTitleError = (title: string): string | undefined => { const length = title.trim().length; if (length === 0 || length > MAX_ARTIFACT_TITLE_LENGTH) { return `Artifact titles must contain 1 to ${MAX_ARTIFACT_TITLE_LENGTH} characters.`; } return undefined; }; export const artifactDescriptionError = ( description: string | null | undefined, ): string | undefined => { if (description !== null && description !== undefined) { if (description.trim().length > MAX_ARTIFACT_DESCRIPTION_LENGTH) { return `Artifact descriptions must contain at most ${MAX_ARTIFACT_DESCRIPTION_LENGTH} characters.`; } } return undefined; }; export const artifactFileError = (fileName: string, content: string): string | undefined => { if (!isValidArtifactName(fileName)) { return "Artifacts must be .html files with a plain filename."; } if (Buffer.byteLength(content, "utf8") > MAX_ARTIFACT_SIZE) { return "File is larger than 10 MB."; } return undefined; }; export const titleFromArtifactFileName = (fileName: string): string => { const base = fileName.replace(/\.html$/i, ""); const title = base .replace(/[._-]+/g, " ") .replace(/\b\w/g, (character) => character.toUpperCase()) .trim(); return (title || "Artifact").slice(0, MAX_ARTIFACT_TITLE_LENGTH); };