// lib/apiHandlers.js // The HTTP handlers referenced by routes(cfg) (ARCHITECTURE.md 6.5, 6.6, 9.5). // Each handler speaks the canonical ThemeDTO and the uniform error envelope // (httpUtils.jsonError). The public theme.css route NEVER 5xx's (degrades to // fallback CSS); every other route is admin-guarded and answers JSON. // // express req/res are the per-tenant router's standard objects (server/app.js // mounts plugin routes via plugin_routes_handler.js / error_catcher). Multipart // import uploads arrive on req.files (express-fileupload, useTempFiles) -- see // importTheme for the empty-truthy-Buffer fix. const fs = require("fs"); const themeStore = require("./themeStore"); const activePointer = require("./activePointer"); const cssCache = require("./cssCache"); const { activeThemeIdForRole } = require("./cfgReaders"); const activateEngine = require("./activate"); // activateEngine.activateTheme const { compileTheme } = require("./compile"); const { normalizeTokens } = require("./themeSchema"); const portability = require("./portability"); const builtins = require("./builtins"); const httpUtils = require("./httpUtils"); const apiState = require("./apiState"); const page = require("./page"); // renderEditorShell const { ROLE_PUBLIC, MAX_IMPORT_BYTES } = require("./constants"); const { guardAdmin, jsonError } = httpUtils; // --- PUBLIC: GET /theme.css (ARCHITECTURE.md 6.5, folded cache-poisoning fix) --- async function getThemeCss(req, res, cfg) { const role = req.query.role ? Number(req.query.role) : (req.user?.role_id ?? ROLE_PUBLIC); const themeId = activeThemeIdForRole(cfg, role); res.set("Content-Type", "text/css; charset=utf-8"); if (!themeId) { res.set("Cache-Control", "no-store"); res.send("/* theme-builder: no active theme */"); return; } const roleKey = (cfg.activeByRole && cfg.activeByRole[role]) ? role : null; let entry = cssCache.getCached(themeId, roleKey); let isReal = !!entry; if (!entry) { // lazy compile-on-miss const theme = await themeStore.getById(themeId); if (theme) { const compiled = compileTheme(theme.tokens, { engine: theme.engine }); entry = { ...compiled, themeId, role: roleKey, builtAt: Date.now() }; cssCache.setCached(entry); isReal = true; } } if (!entry) { // theme missing mid-activate, transient res.set("Cache-Control", "no-store"); // FOLDED FIX: never memoize fallback under live hash res.send(builtins.fallbackCss()); return; } // ETag always reflects the per-entry CONTENT hash, so the If-None-Match/304 path can correct // stale CSS even when a ?v cache-buster failed to change. We keep `immutable` ONLY when the // emitted link's ?v is guaranteed to change on re-activation. With the COMPOSITE activeHash // (4.4/7.8) that guarantee now holds for the per-role link too, so `immutable` is safe here; // were that guarantee ever weakened, drop `immutable` (keep ETag + must-revalidate) so the // 304/If-None-Match path can still correct stale role CSS. (folded review fix) res.set("ETag", `"${entry.hash}"`); res.set("Cache-Control", isReal ? "public, max-age=31536000, immutable" : "no-store"); if (req.headers["if-none-match"] === `"${entry.hash}"`) { res.status(304).end(); return; } res.send(entry.css); } // --- GET /editor: SPA shell --- async function getEditor(req, res) { if (!guardAdmin(req, res)) return; // GET -> redirect to login on failure // page.renderEditorShell builds the SPA shell HTML (embeds the /state boot blob + // csrf) and sends it via res.sendWrap (ARCHITECTURE.md 8.2). return page.renderEditorShell(req, res); } // --- GET /api/state --- async function getState_(req, res) { if (!guardAdmin(req, res)) return; res.json(await apiState.buildState(req)); } // --- GET /api/themes/:id --- async function loadTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; const theme = await themeStore.getById(id); // resolves rows AND builtins (SEAM FIX) if (!theme) return jsonError(res, 404, "not_found", "Unknown theme"); // builtins are read-only: surface the flag so the SPA edits via duplicate-to-edit. res.json({ theme, readOnly: !!theme.builtin }); } // --- POST /api/themes: create blank, or clone a template (builtin/theme) --- async function createTheme(req, res) { if (!guardAdmin(req, res)) return; const { name, engine, fromTemplate } = req.body || {}; let theme; if (fromTemplate) { // fromTemplate names a builtin/theme id to seed from -> duplicate to a fresh row. theme = await themeStore.duplicate(fromTemplate, name); if (!theme) return jsonError(res, 404, "not_found", "Unknown template"); } else { theme = await themeStore.create({ name, engine, tokens: {}, layoutTree: null }); } res.json({ theme }); } // --- POST /api/themes/:id/duplicate --- async function duplicateTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; const name = req.body?.name; const theme = await themeStore.duplicate(id, name); // resolves rows AND builtins if (!theme) return jsonError(res, 404, "not_found", "Unknown theme"); res.json({ theme }); } // --- POST /api/themes/:id/save: optimistic CAS on version (ARCHITECTURE.md 6.6) --- async function saveTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Built-in themes are read-only"); const { tokens, layoutTree = null, baseVersion, force } = req.body || {}; if (tokens == null) return jsonError(res, 400, "bad_request", "Missing tokens"); if (baseVersion == null && !force) return jsonError(res, 400, "bad_request", "Missing baseVersion"); const updated = force ? await themeStore.forceUpdate(id, { tokens, layoutTree }) : await themeStore.casUpdate(id, baseVersion, { tokens, layoutTree }); // UPDATE ... WHERE theme_id=$ AND version=$base, version=version+1 if (!updated) { const cur = await themeStore.getById(id); if (!cur) return jsonError(res, 404, "not_found", "Unknown theme"); return jsonError(res, 409, "version_conflict", "Theme was modified by another session", { currentVersion: cur.version }); } res.json({ theme: updated }); // SAVE does NOT touch the live site } // --- POST /api/themes/:id/rename: name only, builtins blocked, de-dups (no 409) --- async function renameTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Built-in themes are read-only"); const name = req.body?.name; if (name == null || String(name).trim() === "") return jsonError(res, 400, "bad_request", "Missing name"); const exists = await themeStore.getById(id); if (!exists) return jsonError(res, 404, "not_found", "Unknown theme"); // store.rename de-dups ("Name (2)") rather than rejecting, so rename always succeeds. const theme = await themeStore.rename(id, name); res.json({ theme }); } // --- POST /api/themes/:id/delete (ARCHITECTURE.md 6.6) --- async function deleteTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; if (builtins.isBuiltinId(id)) return jsonError(res, 403, "builtin_immutable", "Cannot delete built-in"); const r = await themeStore.getById(id); if (!r) return jsonError(res, 404, "not_found", "Unknown theme"); const { activeThemeId, activeByRole } = activePointer.getActivePointer(); const isActive = activeThemeId === id || Object.values(activeByRole).includes(id); if (isActive) { if (!req.body?.autoSwitch) return jsonError(res, 409, "active_theme", "Theme is active; pass autoSwitch to reassign first"); const successor = (await themeStore.firstOther(id))?.id || builtins.defaultId(); await activateEngine.activateTheme(successor); // recompile + flip to successor FIRST await activePointer.dropRoleRefs(id); // scrub activeByRole entries == id } await themeStore.remove(id); res.json({ ok: true, switchedActiveTo: isActive ? undefined : null }); } // --- POST /api/themes/:id/activate: the only live-mutating op (ARCHITECTURE.md 6.6) --- async function activateTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; const exists = builtins.isBuiltinId(id) ? !!builtins.get(id) : !!(await themeStore.getById(id)); if (!exists) return jsonError(res, 404, "not_found", "Unknown theme"); try { const out = await activateEngine.activateTheme(id, req.body?.role); res.json({ ok: true, ...out }); } catch (e) { if (e.stage === "compile") return jsonError(res, 500, "compile_failed", e.message); // pointer untouched return jsonError(res, 500, "activation_failed", e.message); // pointer write/propagate failed } } // --- GET /api/themes/:id/export: JSON envelope as a download (works for builtins) --- async function exportTheme(req, res) { if (!guardAdmin(req, res)) return; const id = req.params.id; const theme = await themeStore.getById(id); // resolves rows AND builtins if (!theme) return jsonError(res, 404, "not_found", "Unknown theme"); const envelope = portability.buildEnvelope(theme); const slug = String(theme.name || "theme").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64) || "theme"; res.set("Content-Type", "application/json; charset=utf-8"); res.set("Content-Disposition", `attachment; filename="${slug}.theme.json"`); res.send(JSON.stringify(envelope, null, 2)); } // --- POST /api/import (ARCHITECTURE.md 9.5, multipart source + fresh-id fixes) --- async function importTheme(req, res) { if (!guardAdmin(req, res)) return; let raw; if (req.files && req.files.file) { const f = req.files.file; if (f.size > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme file too large"); // FOLDED FIX: with useTempFiles, f.data is an EMPTY (truthy) Buffer -> branch on LENGTH, read tempFilePath. raw = (f.data && f.data.length) ? f.data.toString("utf8") : fs.readFileSync(f.tempFilePath, "utf8"); } else if (req.body && (req.body.$schema || req.body.envelope)) { raw = typeof req.body.envelope === "string" ? req.body.envelope : JSON.stringify(req.body.envelope || req.body); if (Buffer.byteLength(raw) > MAX_IMPORT_BYTES) return jsonError(res, 413, "too_large", "Theme too large"); } else return jsonError(res, 400, "bad_request", "No theme file provided"); const parsed = await portability.parseEnvelope(raw); if (!parsed.ok) return jsonError(res, parsed.error.includes("compile") ? 422 : 400, parsed.error.includes("compile") ? "uncompilable" : "bad_format", parsed.error); // (b) FRESH id + de-dup name via create() -> import NEVER overwrites const theme = await themeStore.create({ name: parsed.draft.name, engine: parsed.draft.engine, tokens: parsed.draft.tokens, layoutTree: parsed.draft.layoutTree }); res.json({ theme }); } // --- POST /api/preview-css: compile DRAFT tokens for the editor's live preview --- // Uses the SAME server compiler as production (single source of truth -> the // preview's buttons/badges match exactly, including the deep overlay). Does NOT // save or activate; returns text/css with no-store. async function previewCss(req, res) { if (!guardAdmin(req, res)) return; const tokens = normalizeTokens((req.body && req.body.tokens) || {}); const engine = (req.body && req.body.engine) || undefined; const compiled = compileTheme(tokens, { engine }); res.set("Content-Type", "text/css; charset=utf-8"); res.set("Cache-Control", "no-store"); res.send(compiled.css); } module.exports = { getThemeCss, getEditor, getState_, loadTheme, createTheme, duplicateTheme, saveTheme, renameTheme, deleteTheme, activateTheme, exportTheme, importTheme, previewCss, };