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