syntaxai/tdd.md · main · src / a31_admin_validation.ts
// c31 — model: validation for the admin sxdoc edit form. Pure: no I/O.
// Sibling to c31_edit_validation (markdown-editor validation), but for
// the SxDocument-backed admin UI.
//
// Per Modeled.md: external input (HTTP form bodies) gets a parser in
// c31 before any logic touches it. Handler reads FormData, hands a
// Record<string, string> to validateEditForm, gets back a discriminated
// result the handler can react to.
// Slugs may be single-segment ("about") or multi-segment ("company/about",
// "docs/spec/grammar"). Each segment is lowercase a-z/0-9/-/_. Leading or
// trailing slashes are trimmed by the caller before this regex runs, so
// the pattern itself only matches the canonical "seg(/seg)*" shape.
const SLUG_RE = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/;
// 1 MiB cap on HTML body. The migration's biggest single document
// (sama-meets-git-cms.md) is ~12 KB rendered — 1 MiB is generous
// headroom for any realistic page, while still rejecting accidental
// 50 MB pastes that would block the SQLite WAL.
export const MAX_ADMIN_HTML_BYTES = 1024 * 1024;
export interface ValidatedEditInput {
slug: string;
type: "page" | "post";
title: string;
html: string;
status: "published" | "draft";
primaryTag: string | null;
}
export type AdminValidationResult =
| { ok: true; data: ValidatedEditInput }
| { ok: false; error: string };
export const validateEditForm = (form: Record<string, string>): AdminValidationResult => {
const slug = (form.slug ?? "").trim().toLowerCase().replace(/^\/+|\/+$/g, "");
const type = form.type ?? "";
const title = (form.title ?? "").trim();
const html = form.html ?? "";
const statusRaw = form.status ?? "published";
const primaryTag = (form.primary_tag ?? "").trim() || null;
if (!title) return { ok: false, error: "title is required" };
if (!SLUG_RE.test(slug)) {
return {
ok: false,
error: "slug must be lowercase segments (letters, digits, dash, underscore) joined by single slashes — e.g. about, company/about, docs/spec/grammar",
};
}
if (type !== "page" && type !== "post") {
return { ok: false, error: "type must be page or post" };
}
if (statusRaw !== "published" && statusRaw !== "draft") {
return { ok: false, error: "status must be published or draft" };
}
const bytes = new TextEncoder().encode(html).length;
if (bytes > MAX_ADMIN_HTML_BYTES) {
return {
ok: false,
error: `body exceeds the ${MAX_ADMIN_HTML_BYTES / 1024} KB limit (got ${Math.round(bytes / 1024)} KB)`,
};
}
return {
ok: true,
data: { slug, type, title, html, status: statusRaw, primaryTag },
};
};