Moved from wayBetter for X (scripts/build-store.mjs, scripts/sign-store.mjs) into the `waybetter` npm package, generic over the app: - bin/waybetter.mjs + cli/store.mjs. `build --config <store.config.mjs>`: the config names its app and returns its items (metadata, file path + bytes, optional preview); build replaces only that app's items in the shared catalog (other apps' items and files stay), removes files only its old items used, fills url/sha256/size/apps, and validates the whole catalog with the rules apps apply natively before anything is written. `sign` (and `sign --init`: create the key once, 0600, never overwritten) signs the exact catalog bytes; `verify` checks the signature with any listed public key (rotation) and the catalog rules. The private key is never printed, copied or moved; --key defaults to $WAYBETTER_STORE_KEY. pluginHeader() for configs. - store-catalog.schema.json (format 2, `apps` per item), exported with the CLI module. - Tests: multi-app builds in one folder, refusals (bad items, duplicates, paths outside the folder, hosts), schema vs CLI rules, sign/verify round trip incl. rotation and tampering, no key material printed. jsonSchema.mjs learns minItems, uniqueItems and allOf.
54 lines
3.5 KiB
JavaScript
54 lines
3.5 KiB
JavaScript
// A small JSON Schema (draft 2020-12) validator for the keywords theme.schema.json uses: type,
|
|
// required, properties, additionalProperties, enum, const, pattern, min/maxLength, minimum,
|
|
// maximum, items, minItems, maxItems, uniqueItems, allOf, oneOf, anyOf and local $ref (theme and
|
|
// store catalog schemas). Returns a list of error paths.
|
|
|
|
const typeOf = value => (value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value);
|
|
|
|
function matchesType(value, type) {
|
|
if (type === 'integer') return Number.isInteger(value);
|
|
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
|
return typeOf(value) === type;
|
|
}
|
|
|
|
export function validate(schema, value, root = schema, path = '$') {
|
|
if (schema.$ref) {
|
|
const target = schema.$ref.replace(/^#\//, '').split('/').reduce((node, key) => node?.[key], root);
|
|
if (!target) return [`${path}: unresolved ${schema.$ref}`];
|
|
return validate(target, value, root, path);
|
|
}
|
|
const errors = [];
|
|
if (schema.type && ![].concat(schema.type).some(type => matchesType(value, type))) return [`${path}: expected ${schema.type}, got ${typeOf(value)}`];
|
|
if ('const' in schema && JSON.stringify(schema.const) !== JSON.stringify(value)) errors.push(`${path}: expected ${JSON.stringify(schema.const)}`);
|
|
if (schema.enum && !schema.enum.some(item => JSON.stringify(item) === JSON.stringify(value))) errors.push(`${path}: not one of ${schema.enum.join(', ')}`);
|
|
if (typeof value === 'string') {
|
|
if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) errors.push(`${path}: does not match ${schema.pattern}`);
|
|
if (schema.minLength != null && [...value].length < schema.minLength) errors.push(`${path}: shorter than ${schema.minLength}`);
|
|
if (schema.maxLength != null && [...value].length > schema.maxLength) errors.push(`${path}: longer than ${schema.maxLength}`);
|
|
}
|
|
if (typeof value === 'number') {
|
|
if (schema.minimum != null && value < schema.minimum) errors.push(`${path}: below ${schema.minimum}`);
|
|
if (schema.maximum != null && value > schema.maximum) errors.push(`${path}: above ${schema.maximum}`);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (schema.minItems != null && value.length < schema.minItems) errors.push(`${path}: fewer than ${schema.minItems} items`);
|
|
if (schema.maxItems != null && value.length > schema.maxItems) errors.push(`${path}: more than ${schema.maxItems} items`);
|
|
if (schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) errors.push(`${path}: repeated items`);
|
|
if (schema.items) value.forEach((item, index) => errors.push(...validate(schema.items, item, root, `${path}[${index}]`)));
|
|
}
|
|
if (typeOf(value) === 'object') {
|
|
for (const key of schema.required ?? []) if (!(key in value)) errors.push(`${path}: missing ${key}`);
|
|
for (const [key, item] of Object.entries(value)) {
|
|
const child = schema.properties?.[key];
|
|
if (child) errors.push(...validate(child, item, root, `${path}.${key}`));
|
|
else if (schema.additionalProperties === false) errors.push(`${path}: unknown property ${key}`);
|
|
}
|
|
}
|
|
for (const option of schema.allOf ?? []) errors.push(...validate(option, value, root, path));
|
|
if (schema.oneOf) {
|
|
const passing = schema.oneOf.filter(option => validate(option, value, root, path).length === 0).length;
|
|
if (passing !== 1) errors.push(`${path}: matches ${passing} of oneOf (needs exactly 1)`);
|
|
}
|
|
if (schema.anyOf && !schema.anyOf.some(option => validate(option, value, root, path).length === 0)) errors.push(`${path}: matches none of anyOf`);
|
|
return errors;
|
|
}
|