138 lines
4.6 KiB
JavaScript
138 lines
4.6 KiB
JavaScript
import { parseToolboxCatalog } from '@add-ideas/toolbox-contract';
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
const REVERSE_DNS_ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)+$/;
|
|
const TARGET_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
|
|
function record(value, field) {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
throw new Error(`${field} must be an object.`);
|
|
return value;
|
|
}
|
|
|
|
function string(value, field) {
|
|
if (typeof value !== 'string' || value.trim() === '')
|
|
throw new Error(`${field} must be a non-empty string.`);
|
|
return value;
|
|
}
|
|
|
|
function onlyKeys(value, allowed, field) {
|
|
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
|
|
if (unexpected.length > 0)
|
|
throw new Error(`${field} contains unsupported field: ${unexpected[0]}.`);
|
|
}
|
|
|
|
function usesLatestAlias(value) {
|
|
let decoded = value;
|
|
try {
|
|
decoded = decodeURIComponent(value);
|
|
} catch {
|
|
// An invalid encoded URL will be rejected when the artifact is acquired.
|
|
}
|
|
return /(?:^|[^a-z0-9])latest(?:[^a-z0-9]|$)/i.test(decoded);
|
|
}
|
|
|
|
export function parseReleaseLock(value) {
|
|
const lock = record(value, 'lock');
|
|
onlyKeys(
|
|
lock,
|
|
new Set([
|
|
'$schema',
|
|
'schemaVersion',
|
|
'releaseVersion',
|
|
'portalVersion',
|
|
'catalogue',
|
|
'apps',
|
|
]),
|
|
'lock'
|
|
);
|
|
if (lock.schemaVersion !== 1)
|
|
throw new Error('lock.schemaVersion must be 1.');
|
|
string(lock.releaseVersion, 'lock.releaseVersion');
|
|
if (!SEMVER.test(lock.releaseVersion))
|
|
throw new Error('lock.releaseVersion must use semantic versioning.');
|
|
string(lock.portalVersion, 'lock.portalVersion');
|
|
if (!SEMVER.test(lock.portalVersion))
|
|
throw new Error('lock.portalVersion must use semantic versioning.');
|
|
|
|
const catalogue = record(lock.catalogue, 'lock.catalogue');
|
|
onlyKeys(
|
|
catalogue,
|
|
new Set(['id', 'name', 'home', 'theme']),
|
|
'lock.catalogue'
|
|
);
|
|
string(catalogue.id, 'lock.catalogue.id');
|
|
if (!REVERSE_DNS_ID.test(catalogue.id))
|
|
throw new Error('lock.catalogue.id must be a reverse-DNS identifier.');
|
|
string(catalogue.name, 'lock.catalogue.name');
|
|
string(catalogue.home, 'lock.catalogue.home');
|
|
const theme = record(catalogue.theme, 'lock.catalogue.theme');
|
|
onlyKeys(theme, new Set(['mode', 'brand']), 'lock.catalogue.theme');
|
|
if (!['light', 'dark', 'system'].includes(theme.mode))
|
|
throw new Error('lock.catalogue.theme.mode is invalid.');
|
|
string(theme.brand, 'lock.catalogue.theme.brand');
|
|
|
|
if (!Array.isArray(lock.apps) || lock.apps.length === 0)
|
|
throw new Error('lock.apps must contain at least one pinned app.');
|
|
const ids = new Set();
|
|
const targets = new Set();
|
|
lock.apps.forEach((rawApp, index) => {
|
|
const app = record(rawApp, `lock.apps[${index}]`);
|
|
onlyKeys(
|
|
app,
|
|
new Set(['id', 'version', 'artifact', 'sha256', 'target']),
|
|
`lock.apps[${index}]`
|
|
);
|
|
string(app.id, `lock.apps[${index}].id`);
|
|
if (!REVERSE_DNS_ID.test(app.id))
|
|
throw new Error(
|
|
`lock.apps[${index}].id must be a reverse-DNS identifier.`
|
|
);
|
|
string(app.version, `lock.apps[${index}].version`);
|
|
if (!SEMVER.test(app.version))
|
|
throw new Error(
|
|
`lock.apps[${index}].version must use semantic versioning.`
|
|
);
|
|
string(app.artifact, `lock.apps[${index}].artifact`);
|
|
if (usesLatestAlias(app.artifact))
|
|
throw new Error(
|
|
`lock.apps[${index}].artifact must not use a latest alias.`
|
|
);
|
|
string(app.sha256, `lock.apps[${index}].sha256`);
|
|
if (!SHA256.test(app.sha256))
|
|
throw new Error(
|
|
`lock.apps[${index}].sha256 must be 64 lowercase hexadecimal characters.`
|
|
);
|
|
string(app.target, `lock.apps[${index}].target`);
|
|
if (!TARGET_SLUG.test(app.target))
|
|
throw new Error(`lock.apps[${index}].target must be a safe URL slug.`);
|
|
if (ids.has(app.id))
|
|
throw new Error(`Duplicate app id in lock: ${app.id}.`);
|
|
if (targets.has(app.target))
|
|
throw new Error(`Duplicate app target in lock: ${app.target}.`);
|
|
ids.add(app.id);
|
|
targets.add(app.target);
|
|
});
|
|
return lock;
|
|
}
|
|
|
|
export function buildCatalogue(lock) {
|
|
const catalogue = {
|
|
schemaVersion: 1,
|
|
id: lock.catalogue.id,
|
|
name: lock.catalogue.name,
|
|
home: lock.catalogue.home,
|
|
theme: lock.catalogue.theme,
|
|
apps: lock.apps.map((app) => ({
|
|
manifest: `./apps/${app.target}/toolbox-app.json`,
|
|
enabled: true,
|
|
})),
|
|
};
|
|
return {
|
|
$schema:
|
|
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
|
|
...parseToolboxCatalog(catalogue),
|
|
};
|
|
}
|