syntaxai/tdd.md · main · src / a31_admin_validation.ts

a31_admin_validation.ts 69 lines · 2639 bytes raw
// 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 },
  };
};