waybetter/packages/create-waybetter/tests/jsonSchema.mjs
CRT 877dd8fa58 cli: waybetter store build|sign|verify, the store catalog JSON Schema
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.
2026-09-29 11:12:38 +07:00

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;
}