Skin Config Validator
Validate a user-submitted skin/theme config object — colors, fonts, numbers, CSS custom properties — against a schema you declare, BEFORE you splice it into a stylesheet. verifySkin(config, schema) type-checks every field, allowlists CSS colors and cssvar values (rejecting url(), @import, javascript:, and ; { } breakouts), and returns { ok, value, errors, warnings } — value carries only the fields that passed, safe to apply. It does not mutate or coerce; it reports. No dependencies, Node or browser.
The honest edge
It keeps 'wrong' and 'unknown' apart: a bad type/range/injection is an ERROR, an unknown field is a dropped WARNING (forward-compat), a missing optional field is silent. The `string` type is NOT stylesheet-sanitized — it type-checks and length-caps only; use `cssvar` (the type with the injection allowlist) for anything headed into a style surface. The named-color allowlist is conservative (unknown names rejected, not guessed) and the cssvar check is a safe-character GRAMMAR, not a full CSS value parser — it proves the value can't break out of a declaration, not that it is meaningful CSS. The schema is YOURS: a malformed schema throws (programmer error); only the config is treated as untrusted and reported-not-thrown.
Run it
node loop21-verifyskin.js config.json schema.json
test_loop21-verifyskin.js (18/18: every type path, CSS-injection rejection, unknown-field drop+warn, required-missing error, non-object config reported-not-thrown, multi-error report, 2 ratchet-refusal cases)
Node / browser, no dependencies
The code — every file that ships
loop21-verifyskin.js207 lineson GitHub →
#!/usr/bin/env node
/* loop21-verifyskin.js — a pure, dependency-free, STRICT validator for
user-submitted "skin" config objects, before you apply them to a UI. Runs
identically in a browser (window.LoopGifts.verifySkin) and in Node (require /
this CLI). No DOM, no dependencies.
WHY THIS EXISTS. A theming / skin feature lets a user hand you a small config
object — colors, a font, a few numbers, some CSS custom properties — that you
then splice into a stylesheet or inline style. That is untrusted input landing
in your render surface. The usual answer is to trust it (and ship a CSS
injection, or a broken layout from a typo'd number) or to hand-check a few
fields and miss the rest. This validates the WHOLE object against a declared
schema and tells you, honestly, what is wrong — before a single value is
applied.
WHAT "VERIFY" MEANS HERE. verifySkin does not mutate, coerce, or "fix" your
config. It reads it against a schema and returns a verdict:
{ ok, value, errors, warnings }
`ok` is true only when there are zero errors. `value` is the subset of the
input that passed (known, well-typed fields) — safe to apply. `errors` are
hard failures (wrong type, out-of-range, a disallowed CSS value, a missing
required field). `warnings` are soft (an unknown field that was dropped, a
value clamped-in-spec-but-suspicious). A validator that silently drops the
difference between "wrong" and "unknown" is lying about what it checked; this
one keeps them apart.
THE SCHEMA. A plain object mapping field name -> a small type spec:
{ type: "color" } a CSS color: #rgb / #rrggbb / #rrggbbaa /
rgb()/rgba()/hsl()/hsla() / a named-color from
the allowlist. No url(), no expression, no ; }
{ type: "cssvar" } a CSS custom-property VALUE, allowlisted to a
safe grammar (letters, digits, %, #, spaces,
commas, dots, parens for the color fns above) —
rejects ; { } < > url( javascript: and @import.
{ type: "number", min, max } a finite number, optionally range-checked.
{ type: "integer", min, max} a number with no fractional part.
{ type: "enum", values:[..] }one of a fixed set of strings.
{ type: "boolean" } true / false.
{ type: "string", maxLen } an arbitrary string, optionally length-capped
(NOT applied to a stylesheet without your own
escaping — see the README edge).
Add `required: true` to make a missing field an error (default: optional).
API
verifySkin(config, schema) -> { ok, value, errors, warnings }
`config` the untrusted object (anything; a non-object is one error).
`schema` the field->spec map above (a non-object throws — the schema is
YOURS, so a bad schema is a programmer error, not user input).
`errors` / `warnings` are arrays of { field, message } (field null for
whole-object problems). Never throws on bad `config` — that is the point;
it reports. THROWS only on a malformed `schema`.
Pure function of its inputs. Same code in a browser
(window.LoopGifts.verifySkin) or Node (this CLI / require()).
USAGE
node loop21-verifyskin.js config.json schema.json # prints the verdict JSON
node loop21-verifyskin.js --help
*/
(function (root, factory) {
var api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (typeof window !== "undefined") {
window.LoopGifts = window.LoopGifts || {};
window.LoopGifts.verifySkin = api.verifySkin;
}
root.__loop21VerifySkin = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
// A conservative named-color allowlist — the common CSS keywords. Not
// exhaustive by design: an unknown name is rejected, not guessed.
var NAMED_COLORS = {
black:1, silver:1, gray:1, grey:1, white:1, maroon:1, red:1, purple:1,
fuchsia:1, green:1, lime:1, olive:1, yellow:1, navy:1, blue:1, teal:1,
aqua:1, cyan:1, magenta:1, orange:1, pink:1, brown:1, gold:1, coral:1,
salmon:1, khaki:1, violet:1, indigo:1, turquoise:1, tan:1, beige:1,
ivory:1, crimson:1, chocolate:1, transparent:1
};
var HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
// rgb/rgba/hsl/hsla with only numbers, %, commas, spaces, dots inside.
var COLOR_FN = /^(?:rgb|rgba|hsl|hsla)\(\s*[-0-9.%,\s\/]+\)$/;
// A safe cssvar value: letters, digits, whitespace, and a small punctuation set.
// Explicitly excludes ; { } < > and the url(/@import/expression/javascript vectors.
var SAFE_CSSVAR = /^[A-Za-z0-9#%.,()\-_ \/]*$/;
var DANGER = /(url\s*\(|@import|expression\s*\(|javascript:|[;{}<>])/i;
function isColor(v) {
if (typeof v !== "string") return false;
var s = v.trim();
if (HEX.test(s)) return true;
if (COLOR_FN.test(s)) return true;
if (Object.prototype.hasOwnProperty.call(NAMED_COLORS, s.toLowerCase())) return true;
return false;
}
function isSafeCssVar(v) {
if (typeof v !== "string") return false;
if (DANGER.test(v)) return false;
return SAFE_CSSVAR.test(v);
}
function verifySkin(config, schema) {
if (schema === null || typeof schema !== "object" || Array.isArray(schema))
throw new Error("loop21-verifyskin: schema must be a plain object of field specs");
var errors = [], warnings = [], value = {};
if (config === null || typeof config !== "object" || Array.isArray(config)) {
errors.push({ field: null, message: "config must be a plain object" });
return { ok: false, value: {}, errors: errors, warnings: warnings };
}
var schemaFields = Object.keys(schema);
// 1. unknown fields -> dropped, warned (not an error: forward-compat)
Object.keys(config).forEach(function (k) {
if (schemaFields.indexOf(k) === -1)
warnings.push({ field: k, message: "unknown field dropped (not in schema)" });
});
// 2. each schema field
schemaFields.forEach(function (field) {
var spec = schema[field];
if (spec === null || typeof spec !== "object")
throw new Error("loop21-verifyskin: schema field '" + field + "' has a non-object spec");
var present = Object.prototype.hasOwnProperty.call(config, field);
var v = config[field];
if (!present) {
if (spec.required) errors.push({ field: field, message: "required field missing" });
return;
}
switch (spec.type) {
case "color":
if (isColor(v)) value[field] = v;
else errors.push({ field: field, message: "not a valid/allowlisted CSS color" });
break;
case "cssvar":
if (isSafeCssVar(v)) value[field] = v;
else errors.push({ field: field, message: "cssvar value contains a disallowed character or vector" });
break;
case "number":
if (typeof v !== "number" || !isFinite(v)) { errors.push({ field: field, message: "not a finite number" }); break; }
if (typeof spec.min === "number" && v < spec.min) { errors.push({ field: field, message: "below min " + spec.min }); break; }
if (typeof spec.max === "number" && v > spec.max) { errors.push({ field: field, message: "above max " + spec.max }); break; }
value[field] = v;
break;
case "integer":
if (typeof v !== "number" || !isFinite(v) || Math.floor(v) !== v) { errors.push({ field: field, message: "not an integer" }); break; }
if (typeof spec.min === "number" && v < spec.min) { errors.push({ field: field, message: "below min " + spec.min }); break; }
if (typeof spec.max === "number" && v > spec.max) { errors.push({ field: field, message: "above max " + spec.max }); break; }
value[field] = v;
break;
case "enum":
if (!Array.isArray(spec.values)) throw new Error("loop21-verifyskin: enum field '" + field + "' needs a values array");
if (spec.values.indexOf(v) !== -1) value[field] = v;
else errors.push({ field: field, message: "not one of the allowed values" });
break;
case "boolean":
if (typeof v === "boolean") value[field] = v;
else errors.push({ field: field, message: "not a boolean" });
break;
case "string":
if (typeof v !== "string") { errors.push({ field: field, message: "not a string" }); break; }
if (typeof spec.maxLen === "number" && v.length > spec.maxLen) { errors.push({ field: field, message: "exceeds maxLen " + spec.maxLen }); break; }
value[field] = v;
break;
default:
throw new Error("loop21-verifyskin: unknown spec type '" + spec.type + "' for field '" + field + "'");
}
});
return { ok: errors.length === 0, value: value, errors: errors, warnings: warnings };
}
return { verifySkin: verifySkin, isColor: isColor, isSafeCssVar: isSafeCssVar };
});
// ---- CLI (Node only) ------------------------------------------------------
if (typeof require !== "undefined" && typeof module !== "undefined" && require.main === module) {
var api = (typeof globalThis !== "undefined" ? globalThis : this).__loop21VerifySkin;
var args = process.argv.slice(2);
if (!args.length || args.indexOf("--help") !== -1) {
process.stdout.write(
"loop21-verifyskin — strict, zero-dep validator for user skin config\n" +
" node loop21-verifyskin.js config.json schema.json print the verdict JSON\n" +
" node loop21-verifyskin.js --help\n"
);
process.exit(0);
}
try {
var fs = require("fs");
if (args.length < 2) throw new Error("need a config.json and a schema.json");
var config = JSON.parse(fs.readFileSync(args[0], "utf8"));
var schema = JSON.parse(fs.readFileSync(args[1], "utf8"));
var res = api.verifySkin(config, schema);
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
process.exit(res.ok ? 0 : 2); // nonzero when the config is rejected
} catch (e) {
var msg = e && e.message ? e.message : String(e);
if (msg.indexOf("loop21-verifyskin:") !== 0) msg = "loop21-verifyskin: " + msg;
process.stderr.write(msg + "\n");
process.exit(1);
}
}
test_loop21-verifyskin.js174 lineson GitHub →
#!/usr/bin/env node
/* test_loop21-verifyskin.js — known-answer battery for loop21-verifyskin.
The oracle is OUT OF BAND: every expected verdict below is a literal fact
written by hand, never the output of a second validator. Each case constructs
a config + schema whose correct { ok, value, errors, warnings } is known by
construction.
Run: node test_loop21-verifyskin.js (exit 0 = all pass)
*/
"use strict";
var assert = require("assert");
var { verifySkin, isColor, isSafeCssVar } = require("./loop21-verifyskin.js");
var pass = 0, fail = 0;
function ok(name, fn) {
try { fn(); pass++; console.log(" ok " + name); }
catch (e) { fail++; console.log(" FAIL " + name + " — " + e.message); }
}
// ---- 1. a fully-valid config passes, value echoes the typed fields --------
ok("1 valid config -> ok:true, value carries typed fields", function () {
var schema = {
bg: { type: "color" },
accent: { type: "color" },
radius: { type: "integer", min: 0, max: 32 },
opacity: { type: "number", min: 0, max: 1 },
theme: { type: "enum", values: ["light", "dark"] },
dense: { type: "boolean" }
};
var config = { bg: "#101820", accent: "gold", radius: 8, opacity: 0.9, theme: "dark", dense: true };
var r = verifySkin(config, schema);
assert.strictEqual(r.ok, true); // ORACLE
assert.strictEqual(r.errors.length, 0);
assert.deepStrictEqual(r.value, config); // all fields passed
});
// ---- 2. hex-color forms all accepted ------------------------------------
ok("2 #rgb / #rrggbb / #rrggbbaa colors accepted", function () {
["#fff", "#ffffff", "#ffffffff", "#1A2b3C"].forEach(function (c) {
assert.strictEqual(isColor(c), true, c); // ORACLE
});
});
// ---- 3. rgb()/rgba()/hsl() color functions accepted ---------------------
ok("3 rgb/rgba/hsl functions accepted", function () {
["rgb(10, 20, 30)", "rgba(0,0,0,0.5)", "hsl(210, 50%, 40%)"].forEach(function (c) {
assert.strictEqual(isColor(c), true, c); // ORACLE
});
});
// ---- 4. CSS injection in a color is rejected ----------------------------
ok("4 injection color 'red; } body{...' rejected", function () {
assert.strictEqual(isColor("red; } body{display:none}"), false); // ORACLE
var r = verifySkin({ bg: "red; } body{}" }, { bg: { type: "color" } });
assert.strictEqual(r.ok, false);
assert.strictEqual(r.errors[0].field, "bg");
});
// ---- 5. url() and javascript: rejected as cssvar ------------------------
ok("5 cssvar url()/javascript:/@import rejected", function () {
assert.strictEqual(isSafeCssVar("url(evil.png)"), false); // ORACLE
assert.strictEqual(isSafeCssVar("javascript:alert(1)"), false);
assert.strictEqual(isSafeCssVar("@import 'x'"), false);
assert.strictEqual(isSafeCssVar("a; color: red"), false); // semicolon
});
// ---- 6. a benign cssvar value accepted ----------------------------------
ok("6 benign cssvar 'clamp(1rem, 2vw, 2rem)'-ish accepted", function () {
assert.strictEqual(isSafeCssVar("1.5 2px 10%"), true); // ORACLE
assert.strictEqual(isSafeCssVar("rgba(0,0,0,0.2)"), true);
var r = verifySkin({ shadow: "0 1px 3px" }, { shadow: { type: "cssvar" } });
assert.strictEqual(r.ok, true);
assert.strictEqual(r.value.shadow, "0 1px 3px");
});
// ---- 7. number range enforced -------------------------------------------
ok("7 number out of range is an error", function () {
var schema = { opacity: { type: "number", min: 0, max: 1 } };
assert.strictEqual(verifySkin({ opacity: 1.5 }, schema).ok, false); // ORACLE above max
assert.strictEqual(verifySkin({ opacity: -0.1 }, schema).ok, false); // below min
assert.strictEqual(verifySkin({ opacity: 0.4 }, schema).ok, true); // in range
});
// ---- 8. integer rejects a fractional value ------------------------------
ok("8 integer field rejects 8.5", function () {
var schema = { radius: { type: "integer" } };
assert.strictEqual(verifySkin({ radius: 8.5 }, schema).ok, false); // ORACLE
assert.strictEqual(verifySkin({ radius: 8 }, schema).ok, true);
});
// ---- 9. enum enforces the allowed set -----------------------------------
ok("9 enum rejects an off-list value", function () {
var schema = { theme: { type: "enum", values: ["light", "dark"] } };
assert.strictEqual(verifySkin({ theme: "neon" }, schema).ok, false); // ORACLE
assert.strictEqual(verifySkin({ theme: "light" }, schema).ok, true);
});
// ---- 10. boolean type-checked -------------------------------------------
ok("10 boolean field rejects the string 'true'", function () {
var schema = { dense: { type: "boolean" } };
assert.strictEqual(verifySkin({ dense: "true" }, schema).ok, false); // ORACLE: string != bool
assert.strictEqual(verifySkin({ dense: false }, schema).ok, true);
});
// ---- 11. unknown field -> dropped + warned, NOT an error ----------------
ok("11 unknown field dropped with a warning, ok stays true", function () {
var schema = { bg: { type: "color" } };
var r = verifySkin({ bg: "#000", evilField: "x" }, schema);
assert.strictEqual(r.ok, true); // ORACLE: unknown != error
assert.strictEqual(r.warnings.length, 1);
assert.strictEqual(r.warnings[0].field, "evilField");
assert.strictEqual("evilField" in r.value, false); // dropped from safe value
});
// ---- 12. required field missing -> error --------------------------------
ok("12 required-but-missing field is an error", function () {
var schema = { bg: { type: "color", required: true } };
var r = verifySkin({}, schema);
assert.strictEqual(r.ok, false); // ORACLE
assert.strictEqual(r.errors[0].message, "required field missing");
});
// ---- 13. optional missing field is silent -------------------------------
ok("13 optional missing field is neither error nor warning", function () {
var schema = { bg: { type: "color" } };
var r = verifySkin({}, schema);
assert.strictEqual(r.ok, true); // ORACLE
assert.strictEqual(r.errors.length, 0);
assert.strictEqual(r.warnings.length, 0);
});
// ---- 14. non-object config -> one whole-object error, never throws ------
ok("14 non-object config reported, not thrown", function () {
["a string", 42, null, [1, 2]].forEach(function (bad) {
var r = verifySkin(bad, { bg: { type: "color" } });
assert.strictEqual(r.ok, false); // ORACLE
assert.strictEqual(r.errors[0].field, null);
});
});
// ---- 15. multiple errors all reported (not just the first) --------------
ok("15 several bad fields all reported", function () {
var schema = {
bg: { type: "color" },
radius: { type: "integer" },
theme: { type: "enum", values: ["a"] }
};
var r = verifySkin({ bg: "not-a-color", radius: 1.2, theme: "z" }, schema);
assert.strictEqual(r.ok, false);
assert.strictEqual(r.errors.length, 3); // ORACLE: all three
});
// ---- 16. RATCHET: bad schema (non-object) throws ------------------------
ok("16 ratchet: non-object schema throws", function () {
assert.throws(function () { verifySkin({}, "not a schema"); }, /schema must be/); // ORACLE
});
// ---- 17. RATCHET: unknown spec type throws ------------------------------
ok("17 ratchet: unknown spec type throws (programmer error)", function () {
assert.throws(function () { verifySkin({ x: 1 }, { x: { type: "widget" } }); }, /unknown spec type/);
});
// ---- 18. value carries ONLY passing fields (a rejected field is absent) --
ok("18 rejected field absent from value, valid sibling present", function () {
var schema = { good: { type: "color" }, bad: { type: "integer" } };
var r = verifySkin({ good: "#abc", bad: 1.5 }, schema);
assert.strictEqual(r.ok, false);
assert.strictEqual(r.value.good, "#abc"); // ORACLE: good survives
assert.strictEqual("bad" in r.value, false); // bad excluded
});
console.log("\nloop21-verifyskin: " + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);